How Mermaid diagrams are drawn
Mermaid turns twelve lines of text into a picture, and the interesting part is the middle: nothing in flowchart TD / A --> B says where A goes. This is a tour of the grammar, then the five-pass layered layout algorithm that decides the coordinates and routes the edges — with real JavaScript you can lift — then a worked example of every one of the twenty-four diagram types and the one hard thing each of them hides, and finally where the ceiling is and why it is there.
What is Mermaid?
Mermaid is a text language for diagrams, created by Knut Sveidqvist in 2014. The pitch is one sentence long: a diagram should be text, so it can live beside the code it describes, be reviewed in a pull request, and change in the same commit. A picture in a wiki rots the moment someone renames a service; twelve lines in the repo do not.
It became the default when the platforms started rendering it in place. GitHub shipped native Mermaid in Markdown in 2022; GitLab, Notion and Obsidian all followed. Writing ```mermaid in a README is now the ordinary way developers draw.
Mermaid knows two dozen diagram types — all of them are drawn further down this page. Each has its own small language, but they all share a preamble and a first-line declaration, which is the hook everything else hangs from:
---
title: Release pipeline <- optional YAML front matter
---
%% a comment <- %% to end of line
flowchart TD <- the type, and a direction
A[Push] --> B{Tests?} <- statements
That first meaningful line is also how a file with no extension gets recognized. sequenceDiagram and classDiagram are unambiguous; graph is an ordinary English word, so it only counts when a direction follows it, and soft words like pie, journey, kanban and info need a second supporting line before we believe them.
The grammar, in one screen
A flowchart statement is a chain of node groups joined by links. A node is an id, optionally followed by a bracket pair that carries its label and chooses its shape:
| Written | Shape | Written | Shape |
|---|---|---|---|
A[text] | Rectangle | A{text} | Diamond (decision) |
A(text) | Rounded | A{{text}} | Hexagon |
A([text]) | Stadium | A[(text)] | Cylinder (database) |
A[[text]] | Subroutine | A((text)) | Circle |
A[/text/] | Parallelogram | A[/text\] | Trapezoid |
A>text] | Flag | A(((text))) | Double circle |
Note the two rows on the right of the fifth line. [/ opens both the parallelogram and the trapezoid — which one you get is decided by the closing bracket, /] or \]. A parser that commits to a shape when it sees the opener gets this wrong; you have to scan forward to the closer first and then decide.
Beyond shapes there is subgraph … end for boxes (nestable, each with its own direction), & for saying several things at once (A & B --> C & D is four arrows), classDef/class/:::/style/linkStyle for colour (an author’s colours beat the theme’s, the way Mermaid applies them), HTML such as <small> and <b> inside a label, click for links, and the newer A@{ shape: circle, label: "x" } metadata form.
Parsing an arrow is the fiddly bit
Arrows look simple and are not. The rules that actually matter:
A --> B solid, arrowhead
A --- B solid, no head (three dashes!)
A -.-> B dotted
A ==> B thick
A ~~~ B invisible (spacing only)
A --o B circle head
A --x B cross head
A <--> B heads at both ends
A ----> B longer: pushes B further down the page
A -->|yes| B label, pipe form
A -- yes --> B label, inline form
The trap is the last two lines against the second. -- followed by > is a complete arrow; -- followed by anything else opens an inline label that runs until the closing stroke. So the rule is not “two or more dashes”, it is:
- complete when it is
-{2,}plus a head character, or-{3,}with no head; - opening a label when it is exactly
--with no head after it.
Get that backwards and A --> B --> C parses as one arrow from A to C carrying the label “> B”. Here is the matcher, minus the dotted and thick branches which are the same shape:
function matchLink(s, p) {
let tail = '';
if (s[p] === '<') { tail = 'arrow'; p++; }
let n = 0;
while (s[p + n] === '-') n++;
if (n < 2) return null;
const head = { '>': 'arrow', o: 'circle', x: 'cross' }[s[p + n]] || '';
if (head) return { stroke: 'solid', tail, head, length: n,
end: p + n + 1, label: null };
if (n >= 3) return { stroke: 'solid', tail, head: '', length: n,
end: p + n, label: null };
// exactly two dashes, no head -> an inline label runs to the next run
const rest = s.slice(p + n);
const close = /-{2,}/.exec(rest);
if (!close) return null;
const labelEnd = p + n + close.index;
let end = labelEnd + close[0].length;
const head2 = { '>': 'arrow', o: 'circle', x: 'cross' }[s[end]] || '';
if (head2) end++;
return { stroke: 'solid', tail, head: head2, length: close[0].length,
end, label: s.slice(p + n, labelEnd).trim() };
}
Node ids have a matching subtlety. They may contain - and ., so my-node is one id — but A-->B must stop the id at A. The rule that separates them is one character of lookahead: a - belongs to the id only when the character after it is another id character.
Nothing in the source says where anything goes
Now the real problem. After parsing you have a set of boxes and a set of arrows, and not one coordinate. The source gives you a graph; the screen needs a drawing, and there are infinitely many drawings of the same graph. A good one has properties people can name:
- arrows mostly point the same way, so the diagram reads in a direction;
- arrows are short, and cross each other as little as possible;
- boxes do not overlap;
- a chain of nodes comes out roughly straight rather than zigzagging.
Minimizing edge crossings is NP-hard, even with the nodes already assigned to rows. So nobody solves it. Instead everyone runs the same four-pass heuristic pipeline, published by Sugiyama, Tagawa and Toda in 1981 and used by Graphviz's dot, by dagre (which Mermaid itself uses), and by the engine behind this page — which adds a fifth, to route the edges. Each pass is cheap, each is a well-understood approximation, and together they produce drawings people accept.
Pass 1 — break the cycles
The next three passes all assume the graph is acyclic. Real diagrams are not: retry loops and state machines are cycles on purpose. The fix is to reverse just enough edges to make the graph a DAG, then remember which ones, so the arrowhead is drawn on the end the author actually wrote.
A depth-first search finds them. Any edge that points at a node currently on the DFS stack closes a loop — a back edge — and gets reversed:
function breakCycles(nodes, edges) {
const out = new Map(nodes.map(n => [n.id, []]));
edges.forEach((e, i) => { if (e.from !== e.to) out.get(e.from).push({ to: e.to, i }); });
const state = new Map(); // 1 = on the stack, 2 = finished
const reversed = new Set();
const visit = (id) => {
state.set(id, 1);
for (const { to, i } of out.get(id) || []) {
if (state.get(to) === 1) reversed.add(i); // back edge
else if (state.get(to) !== 2) visit(to);
}
state.set(id, 2);
};
// Start from the sources, so the author's reading order survives.
const indeg = new Map(nodes.map(n => [n.id, 0]));
for (const e of edges) if (e.from !== e.to) indeg.set(e.to, indeg.get(e.to) + 1);
for (const n of nodes) if (!indeg.get(n.id) && !state.get(n.id)) visit(n.id);
for (const n of nodes) if (!state.get(n.id)) visit(n.id);
return reversed;
}
Pass 2 — put every node on a row
A rank is a row. Every edge must go from a lower rank to a higher one, which is what makes the diagram read downward. The simple assignment is longest path: walk the graph in topological order, and put each node one step below the deepest of its predecessors.
for (const v of topo) {
let r = 0;
for (const e of ins(v)) r = Math.max(r, e.src.rank + e.minlen);
v.rank = r;
}
That minlen is where A ----> B pays off: a longer arrow asks for more than one rank of separation, and the author gets the breathing room they drew. Every minlen is also doubled — so every edge spans at least two ranks and has a free rank in the middle. That is where its label will live, as you will see in pass 3; it is the trick dagre, and so Mermaid, uses too.
Longest path alone leaves nodes floating far above the work they feed, so a second pass balances: each node moves to whichever feasible rank makes its edges shortest. Feasible means between its lowest predecessor and its highest successor, and the cost is linear between those two bounds, so the best rank is always one of a handful of candidates. One more term goes into that cost, and it is the one that makes subgraphs look intended: a heavy charge for every rank a node’s subgraph grows by.
const cost = (r) => {
let s = (ins.length - outs.length) * r; // total edge length, give or take a constant
for (const box of spans) // the node's subgraphs, without it
s += W * (Math.max(r, box.hi) - Math.min(r, box.lo));
return s;
};
// candidates: where it is, both bounds, and its boxes' edges
for (let r of [v.rank, lo, hi, ...spans.flatMap((b) => [b.lo, b.hi])]) {
r = Math.min(hi, Math.max(lo, r));
if (cost(r) < cost(best)) best = r;
}
Without that charge, a subgraph called “Wave 1” whose members all start together, but one of which only feeds something three columns over, gets that member pulled out next to its consumer — and the Wave 1 box stretches across the whole picture. With it, the wave stays a column, because that is what the author drew a box around.
Pass 3 — order each row, to cut crossings
Ranks fix the vertical; this pass fixes the horizontal order within each row, and it is the pass that decides whether the picture looks tangled.
First, bookkeeping. An edge spanning several ranks has nothing on the ranks in between, so it would be drawn straight through whatever is there. Insert a dummy node on each intermediate rank; the edge becomes a chain of short edges, and the dummies take part in ordering like anything else. Because pass 2 doubled every minlen, every edge has at least one — and a labelled edge’s middle dummy is given the size of its label. From here on a label is a box like any other: ordered, spaced and kept clear of every node by the same rules, rather than painted onto the middle of a line afterwards and hoped for.
A subgraph gets a border dummy on each side of each rank it spans, chained top to bottom. Its members sort as one block between its two borders, and sibling subgraphs keep a single left-to-right order on every rank — otherwise two boxes that share rows would cross each other.
Then the sweeps: down the ranks, put each node at the barycenter of its neighbours on the rank above; back up, using the rank below. Count the crossings after each sweep and keep the best order seen, stopping after four sweeps without an improvement. Counting is cheap with the accumulator tree of Barth, Jünger and Mutzel — an inversion count over the edges between two rows:
// edges between rows r and r+1, sorted by (north, south) position
for (const p of pairs) {
let i = south(p) + first; // leaf for this south position
tree[i]++;
while (i > 1) {
if (i % 2 === 0) total += tree[i + 1]; // earlier edges landing further right
i >>= 1;
tree[i]++;
}
}
It is a heuristic with no guarantee — minimizing crossings is NP-hard — and it is what every layered graph drawer ships.
Pass 4 — turn order into coordinates
Down the page is easy: each rank becomes a band as tall as its tallest box or label, with a gap below it big enough for the turns edges make there (pass 5) and for the padding and title of every subgraph that closes above it or opens below it.
Across the page is where a layered drawing looks designed or computed, and the standard answer is Brandes–Köpf (2001), the coordinate pass inside dagre and Graphviz alike. It runs four times — aligning each node with its upper or its lower median neighbour, packed toward the left or toward the right — and each run turns the alignments into blocks that share one x and compacts the blocks as tightly as separation allows. The four results are aligned to the narrowest and each node takes the average of its two middle values:
for (const v of verts) {
const four = runs.map((xs) => xs[v.i]).sort((a, b) => a - b);
v.x = (four[1] + four[2]) / 2; // the two middle of four
}
Two details carry most of the weight. A segment between two dummies — the inside of a long edge, or a subgraph border — wins every conflict with an ordinary edge, so long edges run dead straight and a subgraph’s border is one vertical line, which is what makes its box a rectangle. And because every run keeps every pair of neighbours at least their separation apart, so does the average of the middle two: the balanced result never overlaps.
Pass 5 — route the edges
Mermaid draws an edge as a curve through its dummy points. The engine behind this page routes edges orthogonally, the way ELK does when Mermaid is asked to look its best: an edge leaves its box straight down, runs straight through its dummies, and turns only in the gap between two ranks. Three small decisions make that read well:
- Ports. The ends on one side of a box meet in the middle, so a fan of edges leaves as one stem and splits in the gap, and a fan-in arrives as one arrowhead — the shared inheritance triangle UML draws. Only ends that look different at that box (an arrow arriving beside lines leaving) get ports of their own.
- Tracks. Every turn in a gap gets a horizontal track. Two turns can cross in at most two places, and which of them sits higher decides whether they do, so the order is chosen pair by pair to avoid it. Turns that never overlap share a track and line up; turns from one port share a track and become a bus.
- Outlines. The last leg is clipped to the shape that is actually drawn — a diamond’s vertex, a stadium’s curve, a cylinder’s rim — not its bounding box.
// cost(a, b): crossings if a's track is above b's —
// b's drop from above cuts a's run, or a's drop below cuts b's
const cost = (a, b) =>
(inside(b.xa, a) ? 1 : 0) + (inside(a.xb, b) ? 1 : 0);
Finally, direction. The engine only ever lays out top-down; BT, LR and RL are a coordinate transform of the finished picture — flip y, or swap x and y, with each box’s width and height swapped on the way in so the gaps clear the right extent. One engine, four directions, and no second implementation to keep in sync.
Every type, and what each one actually needs
Everything above is about one diagram type. The layered engine serves the six that turn out to be a directed graph of labelled boxes once they are parsed — flowchart, class, state, ER, requirement and C4 — and a sequence diagram gets its own lifeline engine, which ZenUML then draws on too, because ZenUML is a different syntax for the same model.
That leaves the rest, and the rest are not graphs. A radar chart is a polar plot, a treemap is a space-filling partition, a packet diagram is a bit map, a Kanban board is two levels of indentation. There is no shared algorithm hiding in there: each one is short, and each one has exactly one part that is harder than it looks. Here is every one of them — the Mermaid on the left, and on the right the picture this page's own renderer draws from it.
quadrantChart — Quadrant chart
quadrantChart
x-axis Low Reach --> High Reach
y-axis Low Engagement --> High Engagement
quadrant-1 Expand
quadrant-2 Promote
quadrant-3 Re-evaluate
quadrant-4 Improve
Campaign A: [0.3, 0.6]
Campaign B: [0.45, 0.23]
Campaign C: [0.72, 0.81]
The hard part: The numbering. quadrant-1 is the top right, and they run anticlockwise from there — the mathematical convention, and the one thing everybody gets backwards.
requirementDiagram — Requirement diagram
requirementDiagram
requirement wire_speed {
id: 1
text: parse at 1 GB/s
risk: high
verifymethod: test
}
element bench {
type: benchmark
}
bench - verifies -> wire_speed
The hard part: Nothing, geometrically — it is boxes and arrows once the field blocks are read, so it goes straight through the layered engine above. The work is the trace: a requirement nothing satisfies or verifies is the finding worth having.
sankey-beta — Sankey
sankey-beta
Coal,Electricity,178
Gas,Electricity,94
Electricity,Homes,151
Electricity,Industry,79
Electricity,Losses,42
The hard part: The body is not Mermaid — it is plain CSV, three columns, RFC-4180 quoting and all. Nodes exist because a row names them, so a typo makes a node instead of an error, and the only real check left is arithmetic: does what goes in come back out?
xychart-beta — XY chart
xychart-beta
title "Parse throughput"
x-axis [1KB, 100KB, 10MB, 1GB]
y-axis "MB/s" 0 --> 1200
bar [820, 1010, 1140, 960]
line [820, 1010, 1140, 960]
The hard part: Choosing the domain when the author does not. Left alone, the value axis starts at zero and is padded at the top so a line never runs along the frame — and if the author does start a bar axis above zero, that is a finding, because the bars stop being proportional to their values.
block-beta — Block diagram
block-beta
columns 3
reader["Reader"] lexer["Lexer"] parser["Parser"]
block:sinks["Sinks"]
tree["Tree"] stats["Stats"]
end
out["Rendered output"]:2
reader --> lexer
lexer --> parser
The hard part: It is a grid, not a graph: blocks fill columns n in reading order, :2 spans two cells, space leaves a hole, and block: … end nests a whole grid inside one cell. Sizing is a post-order walk, placement a pre-order one.
C4Context — C4
C4Context
Person(dev, "Developer", "Wants to read a file")
Enterprise_Boundary(b, "The browser tab") {
System(app, "OmniViewer", "Opens any format")
SystemDb(idb, "IndexedDB", "Holds the handoff")
}
System_Ext(cdn, "Static host", "Serves the bytes")
Rel(dev, app, "Drops a file on")
Rel(app, idb, "Stashes", "structured clone")
Rel(app, cdn, "Fetches", "HTTPS")
The hard part: The only Mermaid type written as function calls. The shape of the call says what the thing is, and the third argument means different things in different calls — a description for a Person, a technology for a Container.
packet-beta — Packet diagram
packet-beta
title UDP datagram
0-15: "Source Port"
16-31: "Destination Port"
32-47: "Length"
48-63: "Checksum"
The hard part: Splitting a field that crosses a row boundary into one rectangle per row — and refusing to paper over a gap or an overlap in the bit ranges, because being right about the offsets is the entire reason the picture exists.
kanban — Kanban board
kanban
Todo
[Write the packet parser]
docs[Write the article]
wip[In progress]
grammar[Design the grammar]@{ assigned: 'knsv', priority: 'High' }
done[Done]
[Ship the toolkit]
The hard part: Indentation, two levels deep — and the @{ … } block on a card, which carries the ticket, the assignee and the priority. That metadata is what a board is for, so it is drawn rather than folded into the label.
architecture-beta — Architecture diagram
architecture-beta
group api(cloud)[Browser tab]
service ui(server)[Viewer] in api
service worker(server)[Worker pool] in api
service idb(database)[IndexedDB] in api
ui:R -- L:worker
idb:T -- B:ui
The hard part: This is the one type where the author states the layout rather than leaving it to us: db:L -- R:server says the server sits off the database’s left. So the grid is solved by walking the edges from a seed, dropping each neighbour in the cell its side asks for, and spiralling outward only when that cell is taken.
radar-beta — Radar chart
radar-beta
title Format support
axis speed["Speed"], size["File size"], depth["Detail"]
axis offline["Offline"], lint["Linting"]
curve here["This toolkit"]{90, 95, 80, 100, 95}
curve typical["Typical viewer"]{70, 40, 85, 20, 10}
max 100
min 0
The hard part: The keyed value form. {math: 85, science: 90} is reordered onto the axes rather than taken in writing order, which is what keeps a curve correct when somebody inserts an axis above it — and a positional list of the wrong length is reported instead of quietly closing early.
treemap-beta — Treemap
treemap-beta
"Parsing"
"Lexer": 18
"Flowchart grammar": 42
"Other grammars": 31
"Drawing"
"Layered engine": 38
"Charts and boards": 27
"Linting": 16
The hard part: The squarified algorithm: keep adding tiles to the current row while the worst aspect ratio in it is still improving, then commit the row and start another in what is left. Rows run along the shorter side, which is what stops the tiles becoming slivers.
zenuml — ZenUML
zenuml
@Actor Client
@Boundary Router
@EC2 Worker
Client->Router.open(file) {
Worker.parse(bytes) {
if (tooBig) {
Worker.window(prefix)
}
return model
}
return view
}
The hard part: Working out who is calling. Calls nest inside braces rather than running down the page, so the sender of a message is whoever’s block you are standing in — resolve that and the model is exactly the one a sequenceDiagram produces, which is why it draws on the same lifelines.
info — Info
info
The hard part: Mermaid’s smallest diagram — one word, and mermaid.js answers with its version number. There is no mermaid.js here, so the honest answer is what this renderer is.
Three of those thirteen needed no geometry at all. A requirement diagram and a C4 diagram are boxes and arrows once their field blocks and argument lists are read, so they go through the same four passes as a flowchart; ZenUML resolves to participants and messages, so it goes through the sequence engine. That is the payoff for making every parser return the same projection — { nodes, edges, subgraphs } plus whatever else the type has: three of the thirteen cost a parser and nothing else.
And the coverage is now the whole of it. Twenty-four types, and every one of them draws — there is no longer a Mermaid diagram this viewer recognizes, parses, and then declines to render. What is left on the other side of that line is a first line that is not a diagram type at all, which is a spelling problem rather than a missing feature, and the audit says which line it is on.
Where the ceiling is, and why it is there
There is one more thing layout needs that we have not mentioned: how wide is this label? The obvious way to find out is to put the text on the page and measure it. That answer is wrong here, for two reasons: it pins layout to the main thread, and it makes the whole engine impossible to unit-test.
So text is measured arithmetically, from a table of average advance widths per character class — narrow (ijlt.,:;'), wide (mwMW@%), uppercase, digits, and one em for CJK. It lands within a few percent, which is all a box needs, and in exchange layout is a pure function of the model: it runs inside a Web Worker on a diagram with thousands of nodes while the page keeps painting, and every invariant in it — ranks increase along edges, no two boxes on a row overlap, all four directions agree — is a unit test.
That leaves the real limit, which is not the parser. Parsing is one linear pass; it will happily chew through a 200 MB generated dependency graph. Layout is superlinear in the node count, and more to the point a picture with fifty thousand boxes in it is not a picture. So the ceiling is stated rather than discovered: past a couple of thousand nodes the diagram tab stops, says so, and points at the tabs that still answer.
| Tab | Reads | Limit |
|---|---|---|
| Raw / Hex | A byte window | None — any file size |
| Audit / JSON / Stats | The full parse | A bounded prefix, and it says when it truncates |
| Diagram / Image | Parse + layout + render | A node budget, because readability runs out first |
Which is the same split every format on this site uses: window what you view, bound what you parse, and be honest about the difference.
Why a diagram needs a linter
A renderer answers one question: does this draw? That is the less interesting question. A diagram can draw perfectly and still be wrong, and the failures are specific enough to detect:
- Unreachable nodes. Walk forward from every node with no inbound edge. Anything the walk never touches is a region of your diagram nothing leads to — usually the leftovers of an edit. (Self-loops must not count toward in-degree here, or one
A --> Adisqualifies A as a root and strands everything downstream of it.) - Loops. The same back edges pass 1 already found, reported instead of silently reversed — deliberate in a state machine, a modelling bug in a class hierarchy.
- Id collisions. An id used for both a node and a subgraph resolves unpredictably. It draws. It is not what you meant.
- Unlabelled decision branches. A diamond with two ways out and no words on them is the commonest unreadable flowchart there is, and it renders beautifully.
- Dangling references. A
clicknaming a node you renamed; aclassasking for aclassDefthat does not exist; aclassDefnothing uses. - Typo'd ids. In a flowchart, mentioning an id creates it — so
A --> Bbwhere you meantBdraws an extra box rather than failing. Any node that only ever appears as an arrow endpoint, never given a label, is worth pointing at.
None of these stop a renderer. All of them are things you would want told to you before the diagram lands in a pull request — which is the whole argument for the Audit tab, and for running it on the generated files that are too big to draw at all.
OmniViewer opens every file format in your browser — JSON, CSV, Markdown, SVG, YAML and more — powered by the same windowed engine as fastjsonviewer.com and hugecsv.com. Mermaid is one of the formats with dedicated tooling.