✳ 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 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.

On this page What is Mermaid The grammar Parsing an arrow The layout problem 1. Breaking cycles 2. Ranking 3. Ordering 4. Coordinates 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/::: for styling, 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. 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: 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]
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 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
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.