✳ OMNIVIEWER Mermaid viewer SVG viewer ← back

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.

On this page What is Mermaid The grammar Parsing an arrow The layout problem 1. Breaking cycles 2. Ranking 3. Ordering 4. Coordinates 5. Routing Every type, drawn Where the ceiling is Why a linter

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:

WrittenShapeWrittenShape
A[text]RectangleA{text}Diamond (decision)
A(text)RoundedA{{text}}Hexagon
A([text])StadiumA[(text)]Cylinder (database)
A[[text]]SubroutineA((text))Circle
A[/text/]ParallelogramA[/text\]Trapezoid
A>text]FlagA(((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.

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:

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:

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;
}
Why start at the sources. DFS from an arbitrary node reverses an arbitrary set of edges, and the picture flips in ways the author did not write. Seeding from nodes with no inbound edge means the search follows the diagram the way a reader would, and the edges it reverses are the ones that genuinely point backwards.

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:

// 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]
ExpandPromoteRe-evaluateImproveLow ReachHigh ReachLow EngagementHigh EngagementCampaign ACampaign BCampaign C

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
verifieswire_speed«Requirement»id: 1text: parse at 1 GB/srisk: highverifymethod: testbench«Element»type: benchmark

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
Coal → Electricity: 178Gas → Electricity: 94Electricity → Homes: 151Electricity → Industry: 79Electricity → Losses: 42Coal 178Gas 94Electricity 272Homes 151Industry 79Losses 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]
8201,0101,1409608201,0101,140960024048072096012001KB100KB10MB1GBMB/sbar 1line 1

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
ReaderLexerParserTreeStatsRendered output

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 browser tabDrops a file onStashes[structured clone]Fetches[HTTPS]Developer«Person»Wants to read a fileOmniViewer«System»Opens any formatIndexedDB«Database»Holds the handoffStatic host«System, external»Serves the bytes

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"
081624313240485663Source Port0-15Destination Port16-31Length32-47Checksum48-63

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]
Todo2Write the packet parserWrite the articleIn progress1HighDesign the grammar@knsvDone1Ship 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
Browser tab (cloud)ViewerserverWorker poolserverIndexedDBdatabase

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
100SpeedFile sizeDetailOfflineLintingThis toolkit: 90This toolkit: 95This toolkit: 80This toolkit: 100This toolkit: 95Typical viewer: 70Typical viewer: 40Typical viewer: 85Typical viewer: 20Typical viewer: 10This toolkitTypical viewer

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
ParsingFlowchart grammar: 42 (24%)Flowchart grammar42Other grammars: 31 (18%)Other grammars31Lexer: 18 (10%)Lexer18DrawingLayered engine: 38 (22%)Layered engine38Charts and boards: 27 (16%)Charts and boards27Linting: 16 (9.3%)Linting16

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
  }
ALTtooBigClientClientRouterRouterWorkerWorkeropen(file)parse(bytes)window(prefix)modelview

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
Mermaid, drawn hereParser, layout and SVG written into this pageNo mermaid.js · no CDN · works offline

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.

TabReadsLimit
Raw / HexA byte windowNone — any file size
Audit / JSON / StatsThe full parseA bounded prefix, and it says when it truncates
Diagram / ImageParse + layout + renderA 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:

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.

Open the Mermaid viewer →Draw it, lint it, export SVG or PNG — locally, no upload, and no mermaid.js downloaded. How we auto-detect formats →The content-sniffing algorithm that recognizes pasted Mermaid, JSON, YAML, CSV and more.

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.