Turning Mermaid code into shapes

Apr 17, 2026

Steve Ruiz

Mermaid is a great way to describe complex diagrams as text. You write something like:




And then hand it off to a library to turn it into a flowchart showing the process of making coffee.


It's great for documentation or for structuring complex processes, and you might have noticed that LLMs love them. But sharing them (other than as an SVG) or making them interactive hasn't been so easy.

We (after loads of requests) decided to bring Mermaid into tldraw. You can programmatically render Mermaid diagrams or copy and paste Mermaid code and get it rendered into the canvas. All as native, editable shapes. Starting with flowcharts, sequence and state diagrams, and mind maps, you can bring your diagrams more easily into tldraw. Then rearrange them, restyle them, or build on top of them.

Two halves of building a diagram

Unsurprisingly, we leaned heavily on the Mermaid.js library to help us understand what’s going on in a Mermaid diagram and to inform how we should render them. But, we found a gap between the different pieces of the library when it comes to representing the code as a diagram.

It has a parser that builds an internal database of vertices, edges, actors, states, and so on. And it has a renderer that takes all of that and produces an SVG. But there's nothing in between that says "here are your nodes and here's where they ended up.”

You can get the “what” (which nodes exist, how they connect) from the parser and then get the “how” (what the diagram actually looks like) from the SVG. But to take the code and render it as tldraw shapes we needed both of these pieces of the puzzle at once. So we took a multistep approach: parse the diagram to get the semantic data, render it offscreen to get the spatial data, then stitch them back together.

Getting positions from the SVG

Getting the nodes, edges, etc from the Mermaid parser was straightforward, but figuring out the positions from the SVG it rendered took more work. We wound up taking the diagram and putting it into a hidden DOM element and then measuring the result with getBBox().

This let us find each component of the diagram as elements in the SVG. For example - flowchart nodes show up as SVG elements with the class .node, subgraph boundaries are .cluster elements, edge waypoints live in path[data-points] attributes, and so on. For each diagram type, we know which elements to look for and how to read coordinates off them, but had to set up a bit of custom logic for each diagram type.

This not only helped us make sure we’re handling layouts in consistent ways. It also meant we could lean on Mermaid's layout logic for edge cases - and trust me, there are a lot of them. Plus we could focus on the situations where tldraw’s shapes are inherently different from what Mermaid renders.

For example, tldraw's hand-drawn font is wider than Mermaid's default. If we used Mermaid's native sizing, the shapes would end up too small for our text. To fix this, we inflate the font sizes when initializing Mermaid to produce a layout with more room.

Or take self-looping arrows. These weren’t something that are historically used often in tldraw and we needed to think about how to make them work. Starting and ending at the same point wouldn’t work (zero length arrows aren’t allowed in tldraw), so we set the starting and ending points on different sides of the shape.

Creating a blueprint

We needed a way to combine together the information from the semantic data and the spatial data to actually create the shapes in tldraw. The tricky bit is that how this works is distinct for each diagram type. All diagrams have the same four type of elements in Mermaid’s world - nodes, edges, lines, and groups - but each diagram type handles them differently.

We gave each diagram its own converter that merges those types of data into what we call a DiagramMermaidBlueprint. A flowchart converter produces one. A sequence diagram converter produces one. They all look the same to the code that actually creates tldraw shapes.

This made extending the types of diagrams we support incredibly easy. When we wanted to add mind map support we didn’t have to touch any of the actual rendering code. We just wrote a new converter that read Mermaid’s mind map data, measured the SVG, and produced a new blueprint. Everything downstream already knew what to do with it.

Each diagram type is its own puzzle

The converters are where the diagram-specific complexity lives, and each one has its quirks. They have to map Mermaid’s shape vocab (diamonds, hexagons, cylinders, stadiums) to tldraw’s geo types, turn subgraphs into frames - with correct parent-child relationships, and figure out arrow bends from waypoint geometry.

Color mapping wound up being its own little rabbit hole. Mermaid lets you define arbitrary CSS colors on a diagram, but tldraw has a fixed palette of colors. So when someone pastes a diagram with something like classDef important fill:#ff6b6b we need to figure out the closest tldraw color to use. We wound up using a redmean approximation with a bit of a modification to handle very light pastels:

  function nearestTldrawColor(rgb: Color): TLDefaultColorStyle {
      let [r, g, b] = rgb                                                                                                                                               
   
      const max = Math.max(r, g, b)                                                                                                                                     
      const min = Math.min(r, g, b)
      const lightness = (max + min) / 2 / 255
      const chroma = max - min                                                                                                                                          
   
      // For very light pastels, strip the white base and amplify the                                                                                                   
      // chromatic signal so the distance metric can see the hue.
      if (lightness > 0.75 && chroma > 5) {
          const target = 200                                                                                                                                            
          r = Math.round(((r - min) / chroma) * target)
          g = Math.round(((g - min) / chroma) * target)                                                                                                                 
          b = Math.round(((b - min) / chroma) * target)
      }                                                                                                                                                                 
   
      let best: TLDefaultColorStyle = 'black'                                                                                                                           
      let bestDistance = Infinity
      for (const [name, red, green, blue] of TLDRAW_PALETTE) {
          // "Redmean" weighted Euclidean distance                                                                                                                      
          const rMean = (r + red) / 2
          const dR = r - red                                                                                                                                            
          const dG = g - green
          const dB = b - blue
          const distance =
              (2 + rMean / 256) * dR * dR +
              4 * dG * dG +                                                                                                                                             
              (2 + (255 - rMean) / 256) * dB * dB
          if (distance < bestDistance) {                                                                                                                                
              bestDistance = distance                                                                                                                                   
              best = name
          }                                                                                                                                                             
      }           
      return best
  }
  function nearestTldrawColor(rgb: Color): TLDefaultColorStyle {
      let [r, g, b] = rgb                                                                                                                                               
   
      const max = Math.max(r, g, b)                                                                                                                                     
      const min = Math.min(r, g, b)
      const lightness = (max + min) / 2 / 255
      const chroma = max - min                                                                                                                                          
   
      // For very light pastels, strip the white base and amplify the                                                                                                   
      // chromatic signal so the distance metric can see the hue.
      if (lightness > 0.75 && chroma > 5) {
          const target = 200                                                                                                                                            
          r = Math.round(((r - min) / chroma) * target)
          g = Math.round(((g - min) / chroma) * target)                                                                                                                 
          b = Math.round(((b - min) / chroma) * target)
      }                                                                                                                                                                 
   
      let best: TLDefaultColorStyle = 'black'                                                                                                                           
      let bestDistance = Infinity
      for (const [name, red, green, blue] of TLDRAW_PALETTE) {
          // "Redmean" weighted Euclidean distance                                                                                                                      
          const rMean = (r + red) / 2
          const dR = r - red                                                                                                                                            
          const dG = g - green
          const dB = b - blue
          const distance =
              (2 + rMean / 256) * dR * dR +
              4 * dG * dG +                                                                                                                                             
              (2 + (255 - rMean) / 256) * dB * dB
          if (distance < bestDistance) {                                                                                                                                
              bestDistance = distance                                                                                                                                   
              best = name
          }                                                                                                                                                             
      }           
      return best
  }
  function nearestTldrawColor(rgb: Color): TLDefaultColorStyle {
      let [r, g, b] = rgb                                                                                                                                               
   
      const max = Math.max(r, g, b)                                                                                                                                     
      const min = Math.min(r, g, b)
      const lightness = (max + min) / 2 / 255
      const chroma = max - min                                                                                                                                          
   
      // For very light pastels, strip the white base and amplify the                                                                                                   
      // chromatic signal so the distance metric can see the hue.
      if (lightness > 0.75 && chroma > 5) {
          const target = 200                                                                                                                                            
          r = Math.round(((r - min) / chroma) * target)
          g = Math.round(((g - min) / chroma) * target)                                                                                                                 
          b = Math.round(((b - min) / chroma) * target)
      }                                                                                                                                                                 
   
      let best: TLDefaultColorStyle = 'black'                                                                                                                           
      let bestDistance = Infinity
      for (const [name, red, green, blue] of TLDRAW_PALETTE) {
          // "Redmean" weighted Euclidean distance                                                                                                                      
          const rMean = (r + red) / 2
          const dR = r - red                                                                                                                                            
          const dG = g - green
          const dB = b - blue
          const distance =
              (2 + rMean / 256) * dR * dR +
              4 * dG * dG +                                                                                                                                             
              (2 + (255 - rMean) / 256) * dB * dB
          if (distance < bestDistance) {                                                                                                                                
              bestDistance = distance                                                                                                                                   
              best = name
          }                                                                                                                                                             
      }           
      return best
  }

Sequence diagrams wound up being the most involved. For these we had to create paired actor shapes at the top and bottom, add lifelines between them, signal arrows with precise bindings, note shapes, and fragment frames for loop/alt/opt blocks.

Rendering the blueprint into shapes

Even once we have the blueprint we don’t actually have the shapes, just a representation of what and where they’ll be.

renderBlueprint takes a blueprint and does the actual shape creation. It centers the diagram at the paste point, creates shapes in parent-first order (frames need to exist before their children). It then builds arrows with bindings, groups everything together, and runs a correction pass on positions.

One thing we had to make sure we tackled here was splitting out the coordinate systems for the diagram as a whole vs shapes inside frames. Blueprint positions are relative to the Mermaid SVG's coordinate space, but shapes inside frames need positions relative to their parent. A node at position (200, 400) in the blueprint might need to be at (50, 30) relative to the frame it lives in. We handle this with a transform pass that walks the parent hierarchy and adjusts coordinates, but getting it right for deeply nested subgraphs (subgraphs inside subgraphs) took a few iterations.

At this point the system is fully agnostic about what type of diagram it’s creating. It doesn’t matter if it's a flowchart or a state diagram, a shape is a shape and it knows where it’s being placed.

Opening it up for custom shapes

Our first pass linked every diagram type and node to a specific tldraw shape. A flowchart rectangle was always a rectangle. A database shape always became an oval. That was fine for built-in shapes, but meant custom shapes were off the table.

That felt limiting, so we updated it so that each blueprint node carries a render spec describing how it should be created:

  render: {                                                                                                                                                             
      variant: 'geo'                                                                                                                                                    
      geo: TLGeoShapeGeoStyle                                                                                                                                           
  } | {           
      variant: 'shape'                                                                                                                                                  
      type: string
      props: Record<string, unknown>                                                                                                                                    
  }
  render: {                                                                                                                                                             
      variant: 'geo'                                                                                                                                                    
      geo: TLGeoShapeGeoStyle                                                                                                                                           
  } | {           
      variant: 'shape'                                                                                                                                                  
      type: string
      props: Record<string, unknown>                                                                                                                                    
  }
  render: {                                                                                                                                                             
      variant: 'geo'                                                                                                                                                    
      geo: TLGeoShapeGeoStyle                                                                                                                                           
  } | {           
      variant: 'shape'                                                                                                                                                  
      type: string
      props: Record<string, unknown>                                                                                                                                    
  }

The converter decides the mapping. The renderer just follows instructions. You can now pass a mapNodeToRenderSpec per diagram type when calling createMermaidDiagram for complete control over how nodes map to shapes. The system preserves the bindings so arrows don’t get messed up even when you swap in custom shapes.

We’ve had fun seeing where the custom render specs can take us - from completely changing the styles of our mind maps to making an interactive CI pipeline with custom node shapes that let you re-trigger failed CI jobs.


Don't load what you don't need

Mermaid is a pretty chunky package and most people using tldraw.com aren’t going to be interacting with Mermaid diagrams. So we had to only load it when it’s actually important and not make it hurt performance.

When text gets pasted, a component we called SneakyMermaidHandler runs a quick check first. It strips out frontmatter and comments, then looks at whether the text starts with a known Mermaid keyword. No imports, no DOM rendering, just string matching:

  const DIAGRAM_KEYWORD_REGEX =                                                                                                                                         
      /^\\s*(flowchart|graph|sequenceDiagram|classDiagram|stateDiagram|erDiagram|journey|gantt|pie|gitGraph|mindmap|timeline|sankey|xychart|block|quadrantChart|requireme
  nt|C4Context|C4Container|C4Component|C4Dynamic|C4Deployment|packet|kanban|architecture|treemap|radar|info)/                                                           
                  
  export function simpleMermaidStringTest(text: string): boolean {                                                                                                      
      return DIAGRAM_KEYWORD_REGEX.test(
          stripMermaidBoilerplate(stripMarkdownMermaidFence(text))
      )                                                                                                                                                                 
  }
  const DIAGRAM_KEYWORD_REGEX =                                                                                                                                         
      /^\\s*(flowchart|graph|sequenceDiagram|classDiagram|stateDiagram|erDiagram|journey|gantt|pie|gitGraph|mindmap|timeline|sankey|xychart|block|quadrantChart|requireme
  nt|C4Context|C4Container|C4Component|C4Dynamic|C4Deployment|packet|kanban|architecture|treemap|radar|info)/                                                           
                  
  export function simpleMermaidStringTest(text: string): boolean {                                                                                                      
      return DIAGRAM_KEYWORD_REGEX.test(
          stripMermaidBoilerplate(stripMarkdownMermaidFence(text))
      )                                                                                                                                                                 
  }
  const DIAGRAM_KEYWORD_REGEX =                                                                                                                                         
      /^\\s*(flowchart|graph|sequenceDiagram|classDiagram|stateDiagram|erDiagram|journey|gantt|pie|gitGraph|mindmap|timeline|sankey|xychart|block|quadrantChart|requireme
  nt|C4Context|C4Container|C4Component|C4Dynamic|C4Deployment|packet|kanban|architecture|treemap|radar|info)/                                                           
                  
  export function simpleMermaidStringTest(text: string): boolean {                                                                                                      
      return DIAGRAM_KEYWORD_REGEX.test(
          stripMermaidBoilerplate(stripMarkdownMermaidFence(text))
      )                                                                                                                                                                 
  }

Only when this passes do we dynamically import the full Mermaid package. If the text isn't a diagram, it falls through to the normal paste handler. If it's a diagram type we don't support yet, we fall back to importing the raw SVG and show a toast explaining why.

What made this work

It took a few different approaches, but the blueprint abstraction we landed on made the whole system work. Every new diagram type is just a new converter — the parsing, measuring, and rendering code stays the same. When we added mind maps, it meant we just had to add one file - everything else was already there.

If you want to build on top of it, the @tldraw/mermaid package* exports createMermaidDiagram, renderBlueprint, and all the types and helpers for custom shape mapping.


*Full support for @tldraw/mermaid is coming in the tldraw 5.0 release. In the meantime you can start testing it out with our latest canary builds.

Join the community

45K

Source available on

72K

followers on

...

weekly downloads on

8.75K

members on

Join the community

45K

Source available on

72K

followers on

...

weekly downloads on

8.75K

members on

© 2026 tldraw

© 2026 tldraw