Mo Sharif
← ~/blog

Mermaid to React Flow: Making Static Diagrams Clickable

Mermaid is everywhere. It's in GitHub READMEs, Notion docs, Confluence pages, and probably in that architecture doc your team wrote six months ago and hasn't updated since.

Mermaid is great for what it is: a text-based way to describe diagrams that renders into SVGs. But the output is static, and that has been bugging me for years. You can't click a node to see what it does. You can't drag it somewhere better. You can't ask an edge what flows through it.

A Mermaid-to-React-Flow parser is a compiler pipeline that turns Mermaid's text syntax into positioned React Flow nodes and edges, so a diagram that used to be a flat image becomes a canvas you can drag, click, and export.

So I built one inside Codelit. Pasted Mermaid now lands on the same interactive architecture canvas, custom nodes and animated edges included, as everything else the product draws. Here's how it works.

Parsing Mermaid is hard because the syntax hides meaning in punctuation: bracket style sets a node's shape, dash style sets an edge's type, and subgraph blocks nest arbitrarily, so the parser has to resolve all three before it can emit a single React Flow node.

My first instinct was "parse the text, make some nodes, done." That lasted about an hour.

Here's a simple flowchart:

Mermaid
graph TD
    A[Client] --> B[API Gateway]
    B --> C[Auth Service]
    B --> D[User Service]
    C --> E[(Database)]
    D --> E

Straightforward, right? Now consider what the parser actually needs to handle:

  • Node shapes: [] is a rectangle, () is rounded, {} is a diamond, [()] is a cylinder (database), [[]] is a subroutine
  • Edge types: --> is solid arrow, -.-> is dotted, ==> is thick, --text--> has a label
  • Subgraphs: nested groups with their own layout
  • Styling directives: style A fill:#f9f,stroke:#333
  • Different diagram types: flowchart, sequence, class, state, ER

Each of these needs different parsing logic, different layout algorithms, and different React Flow representations.

The Codelit importer runs five stages — tokenize, parse into an AST, transform to React Flow node types, lay out with dagre, render — and each stage consumes the previous stage's output and produces a more refined representation of the same graph.

StageWhat goes inWhat comes outWhere it bit me
1. TokenizeRaw Mermaid textTyped tokens with line numbersImplicit nodes — A --> B may declare both nodes for the first time
2. ParseTokensAST of nodes, edges, subgraphs and stylesThe word end inside a node label read as the subgraph terminator
3. TransformASTCodelit node types: service, gateway, database, queue, externalShape is a weak signal for role — a diamond may be a load balancer, not a gateway
4. LayoutUntyped graphx/y coordinates from dagreNested subgraphs need a dagre pass each
5. RenderPositioned graphReact Flow canvas with click handlers and animated edgesNothing yet, and I would like to keep it that way

Break the raw Mermaid text into tokens. This is the lexer stage.

Typescript
interface MermaidToken {
  type: "keyword" | "node" | "edge" | "label" | "style" | "subgraph";
  value: string;
  line: number;
}

function tokenize(input: string): MermaidToken[] {
  const lines = input.split("\n");
  const tokens: MermaidToken[] = [];

  for (const [i, line] of lines.entries()) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith("%%")) continue;

    if (trimmed.match(/^(graph|flowchart)\s+(TD|TB|LR|RL|BT)/)) {
      tokens.push({ type: "keyword", value: trimmed, line: i });
    } else if (trimmed.startsWith("subgraph")) {
      tokens.push({ type: "subgraph", value: trimmed, line: i });
    } else if (trimmed.startsWith("style")) {
      tokens.push({ type: "style", value: trimmed, line: i });
    } else {
      // Parse node and edge definitions
      parseNodeEdgeLine(trimmed, i, tokens);
    }
  }
  return tokens;
}

The tricky part: Mermaid allows implicit node creation. In A --> B, both A and B might be new nodes that haven't been declared anywhere. The tokenizer needs to track which node IDs it's seen and create node tokens on first encounter.

Convert tokens into an abstract syntax tree. Nodes become objects with IDs, labels, and shapes. Edges become connection objects. Subgraphs create nested scopes.

Typescript
interface MermaidAST {
  direction: "TD" | "LR" | "RL" | "BT";
  nodes: Map<string, MermaidNode>;
  edges: MermaidEdge[];
  subgraphs: MermaidSubgraph[];
  styles: Map<string, Record<string, string>>;
}

The parser is recursive-descent. Subgraphs can contain other subgraphs, so the parser needs to handle nesting.

This is where Mermaid-specific representations become React Flow components. Each Mermaid node shape maps to one of Codelit's custom node types:

Typescript
const shapeMapping: Record<string, string> = {
  rectangle: "service", // [Label]
  rounded: "service", // (Label)
  diamond: "gateway", // {Label}
  cylinder: "database", // [(Label)]
  stadium: "queue", // ([Label])
  subroutine: "external", // [[Label]]
  hexagon: "service", // {{Label}}
};

This mapping isn't perfect. A Mermaid diamond might be a load balancer, not a gateway. But it's a good starting point that users can adjust on the canvas.

Mermaid uses its own layout engine. React Flow doesn't include one, so I use dagre to calculate node positions.

Typescript
import dagre from "dagre";

function layoutNodes(
  nodes: TransformedNode[],
  edges: TransformedEdge[],
  direction: string
): TransformedNode[] {
  const g = new dagre.graphlib.Graph();
  g.setDefaultEdgeLabel(() => ({}));
  g.setGraph({
    rankdir: direction === "LR" ? "LR" : "TB",
    nodesep: 80,
    ranksep: 100,
    marginx: 20,
    marginy: 20,
  });

  for (const node of nodes) {
    g.setNode(node.id, {
      width: node.measured?.width ?? 200,
      height: node.measured?.height ?? 80,
    });
  }

  for (const edge of edges) {
    g.setEdge(edge.source, edge.target);
  }

  dagre.layout(g);

  return nodes.map((node) => {
    const pos = g.node(node.id);
    return {
      ...node,
      position: {
        x: pos.x - (node.measured?.width ?? 200) / 2,
        y: pos.y - (node.measured?.height ?? 80) / 2,
      },
    };
  });
}

The nodesep and ranksep values took a lot of tweaking. Too tight and nodes overlap. Too loose and the diagram feels disconnected. 80px and 100px ended up being the sweet spot for most architecture diagrams.

Finally, the laid-out nodes and edges are passed to React Flow. Edges get animated (the data flow visualization), nodes get click handlers for the audit panel, and the whole thing is interactive.

Tsx
<ReactFlow
  nodes={layoutNodes}
  edges={animatedEdges}
  nodeTypes={customNodeTypes}
  onNodeClick={handleNodeAudit}
  fitView
  minZoom={0.1}
  maxZoom={2}
>
  <Background />
  <Controls />
  <MiniMap />
</ReactFlow>

The Mermaid features that cost me the most time were nested subgraphs, sequence diagrams, class-based style inheritance, and duplicate node IDs across subgraphs — none of which dagre or a single-pass tokenizer handles for free.

Subgraph layout. Dagre handles flat graphs well. Nested subgraphs? Not so much. I ended up running dagre separately for each subgraph, then a final pass to position the subgraphs relative to each other. It's not elegant, but it works.

Sequence diagrams. Mermaid sequence diagrams have a completely different syntax and layout model. Participants go left-to-right, messages go top-to-bottom. I built a separate layout engine for these that doesn't use dagre at all. It's a simple column-based layout with message ordering.

Style inheritance. Mermaid lets you define classes and apply them to nodes. classDef important fill:#f00 then class A,B important. The parser needs to resolve these before the transform stage, which means styles are processed in two passes: collect definitions, then apply them.

Node ID collisions. In Mermaid, A[Service A] creates a node with ID A and label Service A. But what if someone writes A[Service A] in one subgraph and A[Different Thing] in another? Mermaid treats them as the same node. React Flow needs unique IDs. My solution: prefix IDs with their subgraph path. subgraph1.A and subgraph2.A are different nodes.

Rendering Mermaid is cheaper and correct most of the time. Parsing it into React Flow only pays for itself when people need to explore, edit, or export the diagram rather than glance at it — which is precisely what an architecture diagram is for.

ApproachWhat it costsBest forWhere it breaks
Mermaid fenced block in a READMENothing — the host renders it for youSmall flows that live next to the code they describeStatic image: no dragging, no per-node detail, no export
mermaid.js rendered inside your own appA dependency and a render stepPreviewing syntax while someone types itYou inherit Mermaid's layout and Mermaid's interaction model
Parse into React FlowA parser, a layout engine, and node components you ownDiagrams people explore, edit, and export to other formatsYou now own every layout decision Mermaid used to make for you

Codelit needed the parser because diagrams arrive from three directions: pasted Mermaid, a prompt run through the AI pipeline, and a GitHub repo the analyzer reads and draws. All three converge on the same node types, which is why the shape mapping in stage 3 matters more than it looks.

Paste a Mermaid diagram into Codelit and you get an interactive canvas in about 2 seconds. The nodes are draggable, clickable, and auditable. The edges show data flow animations. You can modify the diagram visually and export it back to Mermaid syntax, or to Terraform, Docker Compose, and Kubernetes manifests.

The round-trip is the part I'm most proud of. Edit the diagram on the canvas, export to Mermaid, paste it back in your docs. Your documentation stays in sync with your visual architecture.

No. The Mermaid parser is hand-written, and if I were starting over I'd reach for a parser generator like PEG.js or nearley — the hand-written approach was faster to iterate on initially, but it's become harder to maintain as I add support for more Mermaid diagram types.

The layout engine is the weakest part. Dagre is good but not great. ELK would produce better layouts but it's heavier. I'm considering switching to elkjs for v2. The layout quality improvement would be worth the bundle size increase.

If you've got Mermaid diagrams sitting in your docs, import them into Codelit and see what they look like as interactive canvases. The parser handles flowcharts, sequence diagrams, and class diagrams. State diagrams and ER diagrams are coming next.

Your static diagrams deserve to be interactive. And your team deserves to be able to click on a node and actually understand what it does.

Questions people actually ask

Can you turn a Mermaid diagram into an interactive React Flow canvas?
Yes, but not by reusing Mermaid's own rendering. Mermaid compiles to a static SVG, so the practical route is to parse the source text yourself, build an abstract syntax tree of nodes, edges and subgraphs, map each Mermaid shape onto a React Flow node type, and run a layout engine such as dagre to assign coordinates. Codelit does exactly that when you paste Mermaid onto the canvas.
Why do you need dagre if Mermaid already lays diagrams out?
Because Mermaid's layout stays inside Mermaid. It computes positions while rendering its own SVG and does not hand them back in a form React Flow can use, and React Flow ships no layout engine of its own. So the importer recalculates positions with dagre, using a node separation of eighty pixels and a rank separation of one hundred, which is the spacing that stopped architecture diagrams overlapping without making them feel disconnected.
How do you handle nested subgraphs when importing Mermaid?
Dagre handles flat graphs well and nested groups badly, so the importer runs dagre once per subgraph and then does a final pass that positions the subgraphs relative to each other. Node identifiers also get prefixed with their subgraph path, because Mermaid treats the same identifier in two different subgraphs as one node, while React Flow requires every node identifier on the canvas to be unique.
What happens when a Mermaid node label contains the word end?
It used to close the subgraph early. In Mermaid the keyword end terminates a subgraph block, and a naive tokenizer sees the same three letters inside a node label and closes the group in the wrong place. The fix is to treat end as a keyword only when it appears alone on its own line with no other tokens beside it.
Which Mermaid diagram types does the Codelit importer support?
Flowcharts, sequence diagrams and class diagrams are supported today, with state diagrams and entity relationship diagrams next on the list. Flowcharts and class diagrams share the same dagre based layout path. Sequence diagrams do not: participants run left to right and messages run top to bottom, so they use a separate column based layout with explicit message ordering rather than a graph layout algorithm.
Is a hand-written parser or a parser generator better for Mermaid?
A hand-written parser is faster to start and slower to keep. Writing the lexer and the recursive descent parser by hand let me ship flowchart support quickly and debug it by reading the code, but every new diagram family has made it harder to maintain. Starting again I would reach for a parser generator such as nearley or PEG.js, and keep hand-written code only for the shape mapping.