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 four-pass layered layout algorithm that decides the coordinates — 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/::: for styling, 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. 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: a node's rank is one more than the deepest of its predecessors.
// iterate to a fixed point; the graph is acyclic, so this settles
for (let pass = 0; pass < nodes.length; pass++) {
let moved = false;
for (const n of nodes) {
let r = 0;
for (const p of preds.get(n.id)) r = Math.max(r, rank.get(p.id) + p.minlen);
if (r !== rank.get(n.id)) { rank.set(n.id, r); moved = true; }
}
if (!moved) break;
}
That minlen is where A ----> B pays off: a longer arrow asks for more than one rank of separation, and the author gets the vertical breathing room they drew.
Longest path alone leaves nodes floating high above the work they feed, so a second pass tightens: pull each node down to one rank above its earliest successor, but never above its own predecessors. It costs four sweeps and removes most of the empty space.
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, a bookkeeping step. An edge spanning three ranks has nothing on the ranks in between, so it would be drawn as a straight line through whatever happens to be there. Insert a dummy node on each intermediate rank; the edge becomes a chain of short edges, the dummies take part in ordering like anything else, and their positions become the bend points of the final polyline.
Then the median heuristic: sweep down the ranks, and put each node at the median position of its neighbours on the rank above. Sweep back up. Repeat about four times, which is where the crossing count stops improving on real diagrams.
function sweep(layer, neighbourPos, adj) {
const median = new Map();
layer.forEach((item, i) => {
const ps = (adj.get(item.id) || [])
.map(x => neighbourPos.get(x))
.filter(v => v != null)
.sort((a, b) => a - b);
// no neighbour on that side -> keep your place
median.set(item.id, ps.length ? ps[(ps.length - 1) >> 1] : i);
});
layer.sort((a, b) => median.get(a.id) - median.get(b.id));
}
Median rather than mean, because the mean is dragged around by one distant neighbour while the median ignores it. It is a heuristic with no guarantee, and it is what every layered graph drawer ships.
Subgraphs get one extra constraint here: members of the same box are kept adjacent before the sweeps begin, and the sort is stable within a group. Without it the cluster rectangle ends up enclosing nodes that are not in the cluster, which is worse than a few extra crossings.
Pass 4 — turn order into coordinates
Two axes, two different jobs.
Down the page is easy: each rank sits below the tallest box on the rank above it, plus a fixed gap. Across the page starts easy — lay each row out left to right at a minimum separation — and then does the one thing that makes a layered drawing look designed instead of computed: it straightens. Each node is pulled toward the average position of everything it connects to, and then the row is pushed apart again so nothing overlaps.
for (let pass = 0; pass < 6; pass++) {
for (const layer of layers) {
for (const item of layer) {
const ns = neighbours(item).map(byId.get, byId).filter(Boolean);
if (ns.length) item.desired = ns.reduce((s, m) => s + m.x, 0) / ns.length;
}
relax(layer); // honour `desired`, then enforce separation
}
}
function relax(items) {
for (const n of items) if (n.desired != null) n.x = n.desired;
items.sort((a, b) => a.x - b.x);
for (let i = 1; i < items.length; i++) {
const min = items[i - 1].x + items[i - 1].w / 2 + SEP + items[i].w / 2;
if (items[i].x < min) items[i].x = min; // push right, never overlap
}
}
The separation pass always wins over the straightening pass. That ordering is the whole trick: the drawing gets as straight as it can without ever letting two boxes touch.
Finally, direction. The engine only ever lays out top-down; BT, LR and RL are a coordinate transform applied to the finished picture — flip y, or swap x and y. 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 four-pass 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.