Building a Live Mermaid Flowchart Renderer in .NET MAUI
This article is part of the #MAUIUIJuly initiative by Matt Goldman. You'll find other helpful articles and tutorials published daily by community members and experts there, so make sure to check it out every day.
Let's learn how to build a live Mermaid-to-visual renderer inside a .NET MAUI app.
Instead of converting Mermaid text into static XAML files, we parse Mermaid flowchart syntax and draw shapes at runtime with MAUI controls.
Mermaid is great for writing diagrams as text. However, in many production apps, we need to:
- Let users edit and preview diagrams instantly.
- Render diagrams in cross-platform mobile/desktop apps (Android, iOS, Windows, Mac).
- Apply app-specific themes and styling dynamically.
- Avoid the complexity of generating and compiling dynamic XAML files.
This implementation is useful when you want a native MAUI preview/editor experience for flowcharts, especially in internal tools, documentation apps, workflow apps, and education platforms.
This implementation supports the following cases:
-
flowchart TDandflowchart LR - Rectangle nodes:
A[Start] - Decision nodes:
B{Valid?} - Edges:
A --> B - Labeled edges:
A -->|Yes| C
Project architecture
We split responsibilities into clear layers:
- Models: graph data structures (nodes, edges, graph)
- Enums: diagram direction and node type
- Parsing: Convert Mermaid text into a graph model
- Rendering: Create MAUI visual elements from the graph model
- Page: UI interaction (input text + render button)
Folder structure:
Step-by-step tutorial
1) Add enums first
Enums define core rendering behavior and remove magic strings from parser/renderer logic.
FlowDirections controls connection direction and layout axis:
namespace MermaidToXAMLApp.Mermaid.Enums;
public enum FlowDirections
{
TD, // Top-Down
LR // Left-Right
}
Here is the code for the NodeKind enum:
namespace MermaidToXAMLApp.Mermaid.Enums;
public enum NodeKind
{
Rect,
Decision
}
2) Add models
Models represent the canonical graph structure that parser and renderer share.
GraphModel is the central object passed from parser to renderer.
First, we define a NodeModel class:
using MermaidToXAMLApp.Mermaid.Enums;
namespace MermaidToXAMLApp.Mermaid.Models;
public record NodeModel(string Id, string Text, NodeKind Kind);
Next, EdgeModel class:
namespace MermaidToXAMLApp.Mermaid.Models;
public record EdgeModel(string From, string To, string? Label);
Here is the code for the GraphModel class:
using MermaidToXAMLApp.Mermaid.Enums;
namespace MermaidToXAMLApp.Mermaid.Models;
public class GraphModel
{
public FlowDirection Direction { get; set; } = FlowDirection.TD;
public Dictionary<string, NodeModel> Nodes { get; } = new();
public List<EdgeModel> Edges { get; } = new();
}
3) Build the parser
Once models exist, we can transform Mermaid text into data.
Parse edges first using token-based node extraction (A[Start] --> B{Valid?}), otherwise node count may remain zero.
Here is the code for MermaidParser static class:
using System.Text.RegularExpressions;
using MermaidToXAMLApp.Mermaid.Enums;
using MermaidToXAMLApp.Mermaid.Models;
namespace MermaidToXAMLApp.Mermaid.Parsing;
public static class MermaidParser
{
private static readonly Regex HeaderRegex = new(@"^flowchart\s+(TD|LR)\s*$", RegexOptions.IgnoreCase);
private static readonly Regex EdgeRegex = new(@"^(?<left>.+?)\s*-->\s*(?:\|(?<label>.+?)\|)?\s*(?<right>.+?)$");
private static readonly Regex RectNodeRegex = new(@"^(?<id>[A-Za-z0-9_]+)\[(?<text>.+)\]$");
private static readonly Regex DecisionNodeRegex = new(@"^(?<id>[A-Za-z0-9_]+)\{(?<text>.+)\}$");
private static readonly Regex IdOnlyRegex = new(@"^(?<id>[A-Za-z0-9_]+)$");
public static GraphModel Parse(string mermaid)
{
var graph = new GraphModel();
var lines = mermaid
.Replace("\r", "\n")
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(l => l.Trim())
.Where(l => !string.IsNullOrWhiteSpace(l))
.ToList();
foreach (var raw in lines)
{
var line = raw.Trim();
if (line.StartsWith("%%")) // Mermaid comment
continue;
var header = HeaderRegex.Match(line);
if (header.Success)
{
graph.Direction = header.Groups[1].Value.Equals("LR", StringComparison.OrdinalIgnoreCase)
? FlowDirection.LR
: FlowDirection.TD;
continue;
}
// Important: edge first
var edgeMatch = EdgeRegex.Match(line);
if (edgeMatch.Success)
{
var leftToken = edgeMatch.Groups["left"].Value.Trim();
var rightToken = edgeMatch.Groups["right"].Value.Trim();
var label = edgeMatch.Groups["label"].Success ? edgeMatch.Groups["label"].Value.Trim() : null;
var leftNode = ParseNodeToken(leftToken);
var rightNode = ParseNodeToken(rightToken);
UpsertNode(graph, leftNode);
UpsertNode(graph, rightNode);
if (leftNode is not null && rightNode is not null)
graph.Edges.Add(new EdgeModel(leftNode.Id, rightNode.Id, label));
continue;
}
// Standalone node
var node = ParseNodeToken(line);
if (node is not null)
UpsertNode(graph, node);
}
return graph;
}
private static NodeModel? ParseNodeToken(string token)
{
var rect = RectNodeRegex.Match(token);
if (rect.Success)
return new NodeModel(rect.Groups["id"].Value, rect.Groups["text"].Value.Trim(), NodeKind.Rect);
var decision = DecisionNodeRegex.Match(token);
if (decision.Success)
return new NodeModel(decision.Groups["id"].Value, decision.Groups["text"].Value.Trim(), NodeKind.Decision);
var idOnly = IdOnlyRegex.Match(token);
if (idOnly.Success)
{
var id = idOnly.Groups["id"].Value;
return new NodeModel(id, id, NodeKind.Rect);
}
return null;
}
private static void UpsertNode(GraphModel graph, NodeModel? node)
{
if (node is null) return;
if (graph.Nodes.TryGetValue(node.Id, out var existing))
{
var existingIsImplicit = existing.Text == existing.Id;
var incomingIsBetter = node.Text != node.Id;
if (existingIsImplicit && incomingIsBetter)
graph.Nodes[node.Id] = node;
return;
}
graph.Nodes[node.Id] = node;
}
}
4) Add rendering configuration
Styling should be centralized and customizable.
Let's define a RenderStyle class, where EdgeStroke and EdgeThickness define visible connectors and are often the first debug knobs.
namespace MermaidToXAMLApp.Mermaid.Rendering;
public class RenderStyle
{
public Color RectFill { get; set; } = Colors.AliceBlue;
public Color RectStroke { get; set; } = Colors.SteelBlue;
public Color RectText { get; set; } = Colors.Black;
public Color DecisionFill { get; set; } = Color.FromArgb("#FFF4D6");
public Color DecisionStroke { get; set; } = Color.FromArgb("#A86F00");
public Color DecisionText { get; set; } = Color.FromArgb("#5A3B00");
public Color EdgeStroke { get; set; } = Colors.DimGray;
public Color EdgeLabelText { get; set; } = Colors.Black;
public Color EdgeLabelBackground { get; set; } = Colors.White;
public double RectWidth { get; set; } = 150;
public double RectHeight { get; set; } = 64;
public double DecisionWidth { get; set; } = 160;
public double DecisionHeight { get; set; } = 100;
public double Margin { get; set; } = 24;
public double LevelGap { get; set; } = 120;
public double RowGap { get; set; } = 36;
public double ColumnGap { get; set; } = 36;
public double EdgeThickness { get; set; } = 2;
public double ArrowLength { get; set; } = 10;
public double ArrowHalfWidth { get; set; } = 6;
}
We also define a LayoutNode class:
using MermaidToXAMLApp.Mermaid.Models;
namespace MermaidToXAMLApp.Mermaid.Rendering;
internal sealed class LayoutNode
{
public required NodeModel Node { get; init; }
public double X { get; init; }
public double Y { get; init; }
public double W { get; init; }
public double H { get; init; }
}
5) Build the renderer
We already have parsed data and style settings; now we map graph to native visuals.
We will use PathGeometry for edges and set explicit bounds in AbsoluteLayout to ensure proper rendering.
Here is the MermaidRenderer class:
using Microsoft.Maui.Controls.Shapes;
using MermaidToXAMLApp.Mermaid.Enums;
using MermaidToXAMLApp.Mermaid.Models;
namespace MermaidToXAMLApp.Mermaid.Rendering;
public static class MermaidRenderer
{
public static void Render(AbsoluteLayout host, GraphModel graph, RenderStyle style)
{
host.Children.Clear();
if (graph.Nodes.Count == 0) return;
var layout = BuildLayout(graph, style);
var canvasWidth = layout.Max(i => i.Value.X + i.Value.W) + style.Margin;
var canvasHeight = layout.Max(i => i.Value.Y + i.Value.H) + style.Margin;
host.WidthRequest = canvasWidth;
host.HeightRequest = canvasHeight;
static void PlacePath(Path path, double w, double h)
{
AbsoluteLayout.SetLayoutBounds(path, new Rect(0, 0, w, h));
AbsoluteLayout.SetLayoutFlags(path, AbsoluteLayoutFlags.None);
}
// Draw edges
foreach (var edge in graph.Edges)
{
var a = layout[edge.From];
var b = layout[edge.To];
var (x1, y1, x2, y2) = graph.Direction == FlowDirection.TD
? (a.X + a.W / 2, a.Y + a.H, b.X + b.W / 2, b.Y)
: (a.X + a.W, a.Y + a.H / 2, b.X, b.Y + b.H / 2);
var edgeFigure = new PathFigure
{
StartPoint = new Point(x1, y1),
Segments = new PathSegmentCollection
{
new LineSegment { Point = new Point(x2, y2) }
},
IsClosed = false
};
var edgePath = new Path
{
Stroke = style.EdgeStroke,
StrokeThickness = style.EdgeThickness,
Data = new PathGeometry
{
Figures = new PathFigureCollection { edgeFigure }
}
};
PlacePath(edgePath, canvasWidth, canvasHeight);
host.Children.Add(edgePath);
var arrow = CreateArrowHead(x1, y1, x2, y2, style);
PlacePath(arrow, canvasWidth, canvasHeight);
host.Children.Add(arrow);
if (!string.IsNullOrWhiteSpace(edge.Label))
{
var edgeLabel = new Label
{
Text = edge.Label,
TextColor = style.EdgeLabelText,
BackgroundColor = style.EdgeLabelBackground,
FontSize = 12,
Padding = new Thickness(4, 1)
};
var lx = (x1 + x2) / 2 + 4;
var ly = (y1 + y2) / 2 - 10;
AbsoluteLayout.SetLayoutBounds(edgeLabel, new Rect(lx, ly, -1, -1));
AbsoluteLayout.SetLayoutFlags(edgeLabel, AbsoluteLayoutFlags.None);
host.Children.Add(edgeLabel);
}
}
// Draw nodes
foreach (var item in layout.Values)
{
if (item.Node.Kind == NodeKind.Rect)
{
var border = new Border
{
Stroke = style.RectStroke,
StrokeThickness = 2,
BackgroundColor = style.RectFill,
StrokeShape = new RoundRectangle { CornerRadius = 10 },
Content = new Label
{
Text = item.Node.Text,
TextColor = style.RectText,
HorizontalTextAlignment = TextAlignment.Center,
VerticalTextAlignment = TextAlignment.Center,
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center,
FontAttributes = FontAttributes.Bold,
Margin = 8
}
};
AbsoluteLayout.SetLayoutBounds(border, new Rect(item.X, item.Y, item.W, item.H));
AbsoluteLayout.SetLayoutFlags(border, AbsoluteLayoutFlags.None);
host.Children.Add(border);
}
else
{
var cx = item.X + item.W / 2;
var cy = item.Y + item.H / 2;
var left = item.X;
var right = item.X + item.W;
var top = item.Y;
var bottom = item.Y + item.H;
var diamond = new Path
{
Stroke = style.DecisionStroke,
Fill = style.DecisionFill,
StrokeThickness = 2,
Data = new PathGeometry
{
Figures = new PathFigureCollection
{
new PathFigure
{
StartPoint = new Point(cx, top),
Segments = new PathSegmentCollection
{
new LineSegment { Point = new Point(right, cy) },
new LineSegment { Point = new Point(cx, bottom) },
new LineSegment { Point = new Point(left, cy) }
},
IsClosed = true
}
}
}
};
PlacePath(diamond, canvasWidth, canvasHeight);
host.Children.Add(diamond);
var label = new Label
{
Text = item.Node.Text,
TextColor = style.DecisionText,
FontAttributes = FontAttributes.Bold,
HorizontalTextAlignment = TextAlignment.Center,
VerticalTextAlignment = TextAlignment.Center
};
AbsoluteLayout.SetLayoutBounds(label, new Rect(item.X + 18, item.Y + 24, item.W - 36, item.H - 48));
AbsoluteLayout.SetLayoutFlags(label, AbsoluteLayoutFlags.None);
host.Children.Add(label);
}
}
}
private static Dictionary<string, LayoutNode> BuildLayout(GraphModel graph, RenderStyle style)
{
var indegree = graph.Nodes.Keys.ToDictionary(k => k, _ => 0);
var adjacency = graph.Nodes.Keys.ToDictionary(k => k, _ => new List<string>());
foreach (var e in graph.Edges)
{
adjacency[e.From].Add(e.To);
indegree[e.To]++;
}
var levels = graph.Nodes.Keys.ToDictionary(k => k, _ => int.MaxValue);
var queue = new Queue<string>();
foreach (var root in graph.Nodes.Keys.Where(k => indegree[k] == 0))
{
levels[root] = 0;
queue.Enqueue(root);
}
if (queue.Count == 0)
{
var first = graph.Nodes.Keys.First();
levels[first] = 0;
queue.Enqueue(first);
}
while (queue.Count > 0)
{
var current = queue.Dequeue();
foreach (var next in adjacency[current])
{
if (levels[next] > levels[current] + 1)
{
levels[next] = levels[current] + 1;
queue.Enqueue(next);
}
}
}
var maxAssigned = levels.Values.Where(v => v != int.MaxValue).DefaultIfEmpty(0).Max();
foreach (var id in levels.Keys.Where(k => levels[k] == int.MaxValue))
levels[id] = maxAssigned + 1;
var grouped = levels
.GroupBy(kv => kv.Value)
.OrderBy(g => g.Key)
.ToDictionary(g => g.Key, g => g.Select(x => x.Key).ToList());
var result = new Dictionary<string, LayoutNode>();
if (graph.Direction == FlowDirection.TD)
{
foreach (var level in grouped)
{
var x = style.Margin;
var y = style.Margin + level.Key * (style.LevelGap + style.RectHeight);
foreach (var id in level.Value)
{
var node = graph.Nodes[id];
var w = node.Kind == NodeKind.Decision ? style.DecisionWidth : style.RectWidth;
var h = node.Kind == NodeKind.Decision ? style.DecisionHeight : style.RectHeight;
result[id] = new LayoutNode { Node = node, X = x, Y = y, W = w, H = h };
x += w + style.ColumnGap;
}
}
}
else
{
foreach (var level in grouped)
{
var x = style.Margin + level.Key * (style.LevelGap + style.RectWidth);
var y = style.Margin;
foreach (var id in level.Value)
{
var node = graph.Nodes[id];
var w = node.Kind == NodeKind.Decision ? style.DecisionWidth : style.RectWidth;
var h = node.Kind == NodeKind.Decision ? style.DecisionHeight : style.RectHeight;
result[id] = new LayoutNode { Node = node, X = x, Y = y, W = w, H = h };
y += h + style.RowGap;
}
}
}
return result;
}
private static Path CreateArrowHead(double x1, double y1, double x2, double y2, RenderStyle style)
{
var vx = x2 - x1;
var vy = y2 - y1;
var len = Math.Sqrt(vx * vx + vy * vy);
if (len < 0.001) len = 1;
var ux = vx / len;
var uy = vy / len;
var bx = x2 - ux * style.ArrowLength;
var by = y2 - uy * style.ArrowLength;
var px = -uy;
var py = ux;
var lx = bx + px * style.ArrowHalfWidth;
var ly = by + py * style.ArrowHalfWidth;
var rx = bx - px * style.ArrowHalfWidth;
var ry = by - py * style.ArrowHalfWidth;
return new Path
{
Fill = style.EdgeStroke,
Stroke = style.EdgeStroke,
StrokeThickness = 1,
Data = new PathGeometry
{
Figures = new PathFigureCollection
{
new PathFigure
{
StartPoint = new Point(x2, y2),
Segments = new PathSegmentCollection
{
new LineSegment { Point = new Point(lx, ly) },
new LineSegment { Point = new Point(rx, ry) }
},
IsClosed = true
}
}
}
};
}
}
6) Create the UI
Finally, we are ready to use the parser and renderer. Mermaid content will be added to a textbox, and a button will trigger the rendering process.
An AbsoluteLayout control hosts inside a ScrollView element, so large diagrams remain visible.
XAML code for MermaidRenderPage.xaml:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="MermaidToXAMLApp.Pages.MermaidRenderPage"
Title="Live Mermaid Renderer">
<Grid RowDefinitions="Auto,Auto,*" Padding="12" RowSpacing="8">
<Label Text="Mermaid input (flowchart TD/LR)" FontAttributes="Bold" />
<Editor
x:Name="MermaidEditor"
Grid.Row="1"
HeightRequest="150"
FontFamily="Courier New"
AutoSize="TextChanges" />
<Grid Grid.Row="2" RowDefinitions="Auto,*" RowSpacing="8">
<Button Text="Render Diagram" Clicked="OnRenderClicked" />
<ScrollView Grid.Row="1" Orientation="Both">
<AbsoluteLayout x:Name="DiagramHost" />
</ScrollView>
</Grid>
</Grid>
</ContentPage>
MermaidRenderPage.xaml.cs code-behind:
using MermaidToXAMLApp.Mermaid.Parsing;
using MermaidToXAMLApp.Mermaid.Rendering;
namespace MermaidToXAMLApp.Pages;
public partial class MermaidRenderPage : ContentPage
{
public MermaidRenderPage()
{
InitializeComponent();
MermaidEditor.Text = """
flowchart TD
A[Start] --> B{Valid?}
B -->|Yes| C[Process]
B -->|No| D[Error]
C --> E[Done]
""";
}
protected override void OnAppearing()
{
base.OnAppearing();
OnRenderClicked(this, EventArgs.Empty);
}
private void OnRenderClicked(object sender, EventArgs e)
{
var input = MermaidEditor.Text ?? string.Empty;
var graph = MermaidParser.Parse(input);
var style = new RenderStyle
{
EdgeStroke = Colors.Red,
EdgeThickness = 3
};
MermaidRenderer.Render(DiagramHost, graph, style);
}
}
7) Register the page in Shell
Don't forget to define the navigation entry to open the renderer in AppShell (either XAML or C# code):
using MermaidToXAMLApp.Pages;
namespace MermaidToXAMLApp;
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
Items.Add(new ShellContent
{
Title = "Mermaid",
ContentTemplate = new DataTemplate(typeof(MermaidRenderPage))
});
}
}
Let's build and test our app both on Android and on Windows:
Current limitations
Our app is scoped as MVP. It does not yet include:
- Advanced Mermaid syntax (subgraphs, classes, styling directives)
- Orthogonal connectors
- Collision-free layout for complex graphs
- Zoom/pan gestures
- Exporting the diagram to image (png, jpeg)
We can certainly build a V2 of our app and add more features and support.
The code is available on GitHub.
Thank you for reading! I hope that this post was interesting and useful for you. Enjoy the rest of the #MAUIUIJuly publications!



Top comments (0)