mermaid
Mermaid turns a few lines of text into an SVG diagram. You write something like 'flowchart LR\n a --> b' and mermaid gives you back SVG markup you drop into the page. It ships grammars for flowcharts, sequence diagrams, class diagrams, entity relationship diagrams, state diagrams, gantt charts, git graphs, pie charts, mindmaps, timelines, quadrant charts, sankey diagrams, architecture diagrams and a few more. The whole point is that a diagram lives in your repository as text, so it diffs in a pull request and nobody has to open Visio to move one box. GitHub, GitLab, Notion and Obsidian all render mermaid blocks in markdown, which is why most people meet it there first rather than as an npm dependency. As a library it is a browser thing: mermaid.render() needs a DOM to measure text before it can lay anything out.
For diagrams-as-text in docs and repos, mermaid is the default and deserves to be. Just do not put it in a latency-sensitive bundle or expect it to render untrusted input safely without securityLevel set to sandbox.
Use it if
- Your diagrams should live in git next to the code and be reviewable as a text diff instead of being a binary file someone re-exports every few months
- You are building docs, a wiki, or an internal tool and want authors to write diagrams in a markdown code fence without learning a drawing tool
- You need several diagram families (sequence, ER, state, gantt, flowchart) from one dependency and one syntax family rather than four different renderers
- You want the output as inline SVG you can style with CSS variables and your own theme, not a raster image or an iframe to someone else's service
- Bundle size matters: the main chunk is about 154 KB gzipped before the per-diagram chunks load, and the package pulls in 21 direct dependencies including d3, cytoscape, katex, marked, roughjs and dompurify. If you show one flowchart on a marketing page, render it at build time and ship the SVG
- You need the diagram to look exactly a certain way. Mermaid decides layout for you and the escape hatches are thin: you cannot pin a node to a coordinate, edge routing is whatever dagre or elk produces, and long labels reflow in ways that ruin carefully balanced designs
- You are rendering on the server. mermaid.render() measures text with the DOM, so Node needs jsdom plus a font setup, or a headless browser via @mermaid-js/mermaid-cli. Neither is fun in a container, and text metrics differ from the client, so server and client renders will not match pixel for pixel
- You accept diagram source from users. Mermaid runs a parser plus HTML labels over untrusted text, config is settable inside the diagram itself through the %%{init}%% directive, and the default securityLevel of strict exists precisely because loose lets diagram authors bind click handlers. Sandbox mode is the only setting that puts an iframe between the diagram and your page
- Your graph is large. maxEdges defaults to 500 and maxTextSize to 50000 characters, and raising those limits mostly buys you a long synchronous layout that blocks the main thread
Setup reality
npm install mermaid gets you an ESM-only package: the exports map points at dist/mermaid.core.mjs and there is no CommonJS build, so require('mermaid') fails and older Jest setups need transformIgnorePatterns adjusted or a jsdom environment plus ESM support. The default entry lazy loads each diagram type as a separate chunk, which is what you want in a bundler and what breaks if your build tool cannot handle dynamic import (Vite is fine, older webpack configs and some Next.js server components are not). In React you almost always end up with a client-only component: call mermaid.initialize({ startOnLoad: false }) once at module scope, then await mermaid.render() inside an effect, because rendering twice under StrictMode with the same element id throws. Rendering also needs the element to be in the document and visible enough to measure, so a diagram inside a display:none tab renders at zero width. Fonts must be loaded before render or every label is measured against a fallback face and boxes come out the wrong size.
Patterns
Render one diagram and insert the SVGrender-to-svg
import mermaid from 'mermaid';
mermaid.initialize({ startOnLoad: false, theme: 'default' });
const el = document.querySelector('#graph');
const { svg, bindFunctions } = await mermaid.render(
'graph-1',
'flowchart LR\n a[Start] --> b{Ok?}\n b -->|yes| c[Done]\n b -->|no| a',
);
el.innerHTML = svg;
bindFunctions?.(el);The first argument is the id given to the generated SVG element, and it must be unique on the page. Calling render twice with the same id leaves a stale temporary div in the body. bindFunctions attaches click handlers and tooltips; skip it and interactive diagrams are inert.
Render every .mermaid block already in the DOMauto-render-page
import mermaid from 'mermaid';
mermaid.initialize({ startOnLoad: false });
await mermaid.run({ querySelector: '.mermaid' });
// or re-run over a specific set after new content arrives
await mermaid.run({ nodes: document.querySelectorAll('.mermaid:not([data-processed])') });run() marks each element with data-processed, so calling it again is cheap but will not re-render edited source unless you clear that attribute and restore the original text. Prefer this over startOnLoad: true, which fires on DOMContentLoaded and races with anything that injects markdown later.
Check syntax without throwingvalidate-before-render
const result = await mermaid.parse(source, { suppressErrors: true });
if (result === false) {
showEditorError('That is not valid mermaid');
} else {
console.log(result.diagramType); // e.g. 'flowchart-v2'
}Without suppressErrors, parse() throws and render() will paint a red error diagram into your container. Set suppressErrorRendering: true in initialize if you would rather handle failures yourself than have mermaid write an error graphic into the page.
A React component that survives StrictModereact-client-component
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import mermaid from 'mermaid';
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' });
export function Diagram({ chart }: { chart: string }) {
const id = useId().replace(/:/g, '');
const ref = useRef<HTMLDivElement>(null);
const [svg, setSvg] = useState('');
useEffect(() => {
let cancelled = false;
mermaid
.render(`m-${id}`, chart)
.then(({ svg }) => { if (!cancelled) setSvg(svg); })
.catch(() => { if (!cancelled) setSvg(''); });
return () => { cancelled = true; };
}, [chart, id]);
return <div ref={ref} dangerouslySetInnerHTML={{ __html: svg }} />;
}useId gives a stable unique id per instance; the colons it contains are not valid in a CSS selector so strip them. The cancelled flag matters because StrictMode runs the effect twice in development and the two renders resolve out of order. This must be a client component: mermaid touches document at import time.
Set a theme and override colours globallytheme-and-config
mermaid.initialize({
startOnLoad: false,
theme: 'base',
themeVariables: {
primaryColor: '#101418',
primaryTextColor: '#e6e6e6',
primaryBorderColor: '#2a3038',
lineColor: '#5b6472',
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
},
flowchart: { curve: 'basis', htmlLabels: true },
});themeVariables only has an effect with theme: 'base'; pick 'dark' or 'forest' and your overrides are mostly ignored. initialize merges into the current config rather than replacing it, so calling it twice with different objects leaves you with the union of both.
Override config for a single diagramper-diagram-directive
const chart = `%%{init: {'theme':'base','themeVariables':{'lineColor':'#ff6b6b'}}}%%
sequenceDiagram
Client->>Server: subscribe
Server-->>Client: ack`;
const { svg } = await mermaid.render('seq-1', chart);The init directive must be the first line of the source. It is real config injection, which is exactly why you should not run it over diagram text submitted by users: the directive can change securityLevel-adjacent rendering options that you set globally.
Wire node clicks to your own codeclickable-nodes
mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' });
window.openService = (id) => router.push(`/services/${id}`);
const chart = `flowchart TD
api[API] --> db[(Postgres)]
click api call openService("api")
click db href "/services/db" "Open"`;
const el = document.querySelector('#graph');
const { svg, bindFunctions } = await mermaid.render('svc', chart);
el.innerHTML = svg;
bindFunctions?.(el);click ... call needs securityLevel 'loose', which also means any diagram source rendered by that mermaid instance can invoke globals. Keep loose for diagrams you author and strict for anything else, and remember the callback has to be reachable from window.
Keep a pathological diagram from freezing the tabguard-large-diagrams
mermaid.initialize({
startOnLoad: false,
maxTextSize: 50_000, // default, characters of source
maxEdges: 500, // default, edges per diagram
});
if (source.length > 50_000) {
throw new Error('Diagram source too large to render inline');
}Layout is synchronous once it starts, so a 2000-edge flowchart locks the main thread rather than failing fast. Raising maxEdges is usually the wrong fix: split the diagram, or render it to a static SVG at build time.
Swap dagre for ELK on dense flowchartselk-layout
// npm i @mermaid-js/layout-elk
import mermaid from 'mermaid';
import elkLayouts from '@mermaid-js/layout-elk';
mermaid.registerLayoutLoaders(elkLayouts);
mermaid.initialize({ startOnLoad: false, layout: 'elk' });
// or per diagram:
// %%{init: {'layout': 'elk'}}%%ELK produces far fewer edge crossings on wide graphs and is noticeably slower. It only applies to flowcharts and state diagrams; sequence and gantt ignore the layout setting entirely.
Load Iconify packs for architecture diagramsregister-icon-packs
import mermaid from 'mermaid';
mermaid.registerIconPacks([
{
name: 'logos',
loader: () => fetch('https://unpkg.com/@iconify-json/logos@1/icons.json').then((r) => r.json()),
},
]);
const chart = `architecture-beta
group api(logos:aws-lambda)[API]
service db(logos:postgresql)[Database] in api`;The loader is async and mermaid awaits it during render, so a slow or blocked CDN stalls the diagram. Icons that fail to resolve render as a placeholder question mark rather than throwing, which makes typos in icon names easy to miss.
Make rendered SVG stable across runsdeterministic-ids-for-tests
mermaid.initialize({
startOnLoad: false,
deterministicIds: true,
deterministicIDSeed: 'snapshot',
});
const { svg } = await mermaid.render('fixture', chart);
expect(svg).toMatchSnapshot();Without this, mermaid generates random internal ids and every snapshot diff is noise. It does not make output stable across mermaid versions or across machines with different fonts, so pin the version and expect snapshots to churn on upgrade.
Produce SVG outside a browserrender-in-node
# the supported path: headless Chromium via puppeteer
npx -p @mermaid-js/mermaid-cli mmdc -i diagram.mmd -o diagram.svg
# in CI, point it at a config file to control theme and background
npx -p @mermaid-js/mermaid-cli mmdc \
-i docs/arch.mmd -o docs/arch.svg \
-b transparent -t darkImporting mermaid into plain Node and handing it a jsdom document mostly works until text measurement, which jsdom does not implement, so every label collapses to zero width and boxes overlap. mermaid-cli launches real Chromium, which is the reason it works and also the reason your CI image grows by a few hundred megabytes.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @mermaid-js/mermaid-cli | npm | You only need static SVG or PNG files generated in CI and would rather not ship the renderer to the browser at all |
| @viz-js/viz | npm | You want Graphviz DOT layout quality for pure node-and-edge graphs, compiled to WebAssembly with no DOM measurement step |
| nomnoml | npm | You only draw UML-style class and component diagrams and want a much smaller dependency |