Native Mermaid diagrams for Apple platforms — no JavaScript, no WebView, no dependencies. Parse, lay out, and render 30 Mermaid diagram types in pure Swift and CoreGraphics.
import MermaidRender
struct ReleaseFlow: View {
var body: some View {
MermaidView("""
flowchart TD
A[Start] --> B{Choice}
B -->|yes| C[Do it]
B -->|no| D[Skip]
""")
}
}MermaidView follows the environment's light/dark scheme, sizes to the
diagram (scaling down, never up), and degrades unrecognized sources to
readable monospaced text. Prefer images? One call:
let image = MermaidRenderer.image(
source: "sequenceDiagram\n Alice->>Bob: Hello",
theme: DiagramTheme(prefersDark: false)
)Embedding Mermaid today usually means shipping mermaid.js inside a
WKWebView: a JS runtime per diagram, async round-trips, non-native text,
and a web process in your memory footprint. MermaidKit renders the same
source natively and synchronously — every diagram type below renders cold in
under 25 ms on Apple silicon, most in under 12 ms, and results are
cached per (source, theme, spacing).
| MermaidKit | mermaid.js + WKWebView | BeautifulMermaid | |
|---|---|---|---|
| Diagram types | 30 | all | 6 |
| Runtime | Swift + CoreGraphics | JS engine + web process | Swift (elk-swift layout) |
| Dependencies | none | mermaid.js bundle | elk-swift |
| Rendering | sync, ~ms, cached | async round-trip | sync + async |
| Output | NSImage/UIImage, NSAttributedString, SwiftUI |
HTML/SVG in webview | image, SVG, ASCII |
| Layout engine | network-simplex layering, label-space reservation, fixed-side ports | dagre / ELK | elk-swift |
| Layout verification | geometric linter in CI + stability tests | — | — |
| Density control | DiagramSpacing presets |
config | — |
| Syntax coverage | core syntax per type (see matrix) | reference | core syntax, 6 types |
If you need SVG output, iOS 15, or pixel-parity with mermaid.js, those other rows are good choices. MermaidKit's bet is breadth of native type coverage with zero dependencies and machine-checkable layout quality.
Every type, rendered by MermaidKit itself, one image per diagram (light and dark): docs/GALLERY.md.
All 30 types parse their core syntax — the constructs in the mermaid.js
docs' primary examples, which is what the dense fixtures in
Fixtures/diagrams/ exercise. MermaidKit is not a syntax-complete port of
mermaid.js, and the failure mode is deliberate:
- Unknown diagram dialects →
MermaidParser.parsereturnsnil; hosts show the fenced source (that's whatMermaidViewdoes), andMermaidParser.diagnoseexplains why — with a did-you-mean for typos. - Styling/interaction directives (
%%{init:}%%,classDef/class,style,linkStyle,click) → ignored, not fatal: the diagram still parses and renders with MermaidKit's own theme. Ditto comments (%%). - Structural syntax that goes beyond the core — much of it works:
YAML front-matter (all types); flowchart chained edges
(
A --> B --> C),&fan-out, inline-- text -->labels, bidirectional<-->, min-length links,--o/--xheads, edge IDs,:::classtolerance, nestedsubgraphgroup boxes (with innerdirectionand edges that target a group id); the full everyday sequence set — combined fragments (loop/alt+else/opt/par/critical/break, nested) withrectbands, activation bars (->>+/->>-,activate),boxgroupings, notes with<br/>line breaks,actorfigures, typed participants (@{ "type": "database" }), create/destroy, true arrow heads for every token (-x,-),<<->>), autonumber badges; gantt directive lines (never phantom bars) andy/M/sdurations; radar positional values; packet+Nrelative widths; treemap:::class; gitGraphcherry-pick; class generics~T~; ER attribute keys; state composites, forks, choices. Some still doesn't (flowchart@{ shape }). If your diagram parses but drops something you wrote, that's a gap: please open an issue with the source.
Not supported anywhere: HTML in labels other than <br/> line breaks
(sequence messages and notes honor <br/>; other tags render as text),
FontAwesome icons, click callbacks, animations, and mermaid.js theming
directives (theming is DiagramTheme's job).
| Type | Header | Type | Header |
|---|---|---|---|
| architecture | architecture-beta |
packet | packet-beta |
| block | block-beta |
pie | pie |
| C4 | C4Context/C4Container… |
quadrant | quadrantChart |
| class | classDiagram |
radar | radar-beta |
| entity-relationship | erDiagram |
requirement | requirementDiagram |
| flowchart | flowchart/graph |
sankey | sankey-beta |
| gantt | gantt |
sequence | sequenceDiagram |
| gitGraph | gitGraph |
state | stateDiagram-v2 |
| journey | journey |
timeline | timeline |
| kanban | kanban |
treemap | treemap-beta |
| mindmap | mindmap |
xychart | xychart-beta |
| zenuml | zenuml |
treeview | treeView-beta |
| venn | venn-beta |
cynefin | cynefin-beta |
| wardley | wardley-beta |
ishikawa | ishikawa-beta |
| eventmodeling | eventmodeling |
swimlane | swimlane-beta |
Cold parse → layout → render to rasterized pixels (the benchmark forces
rasterization — a deferred-drawing NSImage would flatter the numbers), best
of 3, on an Apple-silicon Mac
(the dense per-type fixtures in this repo — real-world diagrams are usually
smaller). Samples are round-robin across types (sequential per-type sampling
biased late types with accumulated heat — a measured 2x swing), best of
three rounds. RenderBenchmarks fails CI if any type exceeds 250 ms:
| Diagram | Cold render | Diagram | Cold render |
|---|---|---|---|
| architecture | 13.8 ms | packet | 3.1 ms |
| block | 2.9 ms | pie | 1.7 ms |
| c4 | 5.9 ms | quadrant | 3.1 ms |
| class | 9.2 ms | radar | 2.6 ms |
| cynefin | 2.3 ms | requirement | 8.0 ms |
| er | 6.8 ms | sankey | 25.0 ms |
| eventmodeling | 3.5 ms | sequence | 7.9 ms |
| flowchart | 9.8 ms | state | 10.8 ms |
| gantt | 2.9 ms | swimlane | 3.2 ms |
| gitgraph | 2.1 ms | timeline | 3.9 ms |
| ishikawa | 2.0 ms | treemap | 3.0 ms |
| journey | 3.4 ms | treeview | 3.0 ms |
| kanban | 4.1 ms | venn | 1.4 ms |
| mindmap | 7.4 ms | wardley | 2.3 ms |
| zenuml | 5.1 ms | xychart | 1.6 ms |
Rendering is synchronous by design: at these times a first render in a
SwiftUI body is cheaper than a state round-trip, and repeat renders hit the
cache. Input is bounded the same way mermaid.js bounds it:
MermaidParser.maxTextSize (50k chars) caps every source, and
maxEdges (500) caps flowcharts — the one type whose layered layout is
super-linear in edge count. Oversized sources return nil fast; per-type
numeric fields are clamped at parse (durations, bit ranges, tick counts).
Swift 6 language mode, zero concurrency warnings.
Every diagram describes itself: MermaidView exposes a full content
description to VoiceOver ("Flowchart with 12 nodes and 14 connections:
Fenced mermaid block, ..."), attachmentString sets the same text on the
embedded image, and MermaidRenderer.altText(source:) hands it to hosts
directly. Descriptions are generated from the parsed model — type, honest
counts, leading names — deterministically, for all 30 types.
The parser and layout engines never crash on hostile input — empty/garbage
sources, 100k-character labels, deep nesting, duplicate/self-referencing
nodes, NaN/Infinity/1e308 values, CRLF, RTL text. An adversarial suite
(AdversarialInputTests) runs the full parse → layout → lint pipeline on all
of it in CI. Numeric input is sanitized at the parser boundary
(non-finite rejected, magnitude clamped) so geometry can't be poisoned.
Two targets:
- MermaidLayout — platform-free.
MermaidParser.parse(String)→ per-type models →DiagramLayoutEngine.layout(_:measure:)→ pure geometry (frames, polylines). Text measurement is injected (DiagramTextMeasurer), so layout is fully testable without a display server. - MermaidRender — CoreGraphics/CoreText drawing on macOS 14+, iOS 17+,
and visionOS 1+. Building the package requires Xcode 16+ (Swift 6 tools).
The styling inputs are
DiagramTheme(six colors, a categorical palette, and a dark-mode flag) andDiagramSpacing(layout density).
The layered types (flowchart, class, ER, state) use network-simplex layer assignment — the same strategy ELK Layered and Graphviz dot default to — with label-space reservation and declaration-order stability, so diagrams stay compact, labels stay readable, and small edits don't reshuffle the layout (all three properties are enforced by tests).
MermaidLayout includes something unusual: every diagram lowers to a
DiagramScene — a Codable, machine-readable IR of boxes, edge routes, and
labels — and DiagramLayoutLinter checks it against geometric invariants of
good layout (no edge through a box, no edge slicing through bare label text,
no overlapping nodes, no off-canvas or colliding labels, no marks escaping a
plot). The linter runs in this
package's test suite over dense fixtures for all 30 types, so layout
regressions fail CI as geometry ("edge #3 passes through node 'DiagramScene' (165pt inside)"), not as pixel diffs.
The scene IR is also the extension seam: a different backend (SVG, say)
would consume DiagramScene/the layout structs without touching parsing or
layout. Contributions welcome.
MermaidView(source, theme:spacing:)— SwiftUI drop-in; theme defaults to the environment color scheme;spacingis the density knob (.compact/.regular/.comfortable, or custom gaps — consulted by flowchart, class, ER, state, and architecture layouts).DiagramTheme— six colors + a categoricalpalette(node tints, pie slices, sankey bands…); override the palette to re-skin all 23 types at once. See the Theming article in the DocC docs.MermaidRenderer.image(source:theme:spacing:)— one-shot render, auto-sized;renderImage(...)is the async sibling that renders off the calling thread and propagates cancellation (deliberately a distinct name, so the cheap sync cache-hit path stays reachable from async contexts).MermaidRenderer.attachmentString(source:theme:spacing:)— the diagram as a single-attachmentNSAttributedStringfor embedding in text views.MermaidRenderer.pdfData(source:theme:spacing:)— single-page vector PDF from the same layout and draw code; the export/print path.MermaidRenderer.altText(source:)— a VoiceOver-ready description of the diagram's content (see Accessibility below).MermaidRenderer.textMeasurer— the renderer's own CoreText measurer; pass it toDiagramLayoutEngine.layout/DiagramScene.lowerwhen you want layout or lint geometry to match the render exactly.MermaidParser.parse(_:),MermaidDiagram.typeName, and the per-type layout engines are public for hosts that want geometry without pixels.MermaidParser.diagnose(_:)— why a source failed to parse, with line numbers and did-you-mean suggestions for typo'd headers.
DocC catalogs ship with the package (Xcode: Product → Build Documentation; Swift Package Index hosts them):
- MermaidRender — Getting Started · Theming (brand themes, palettes, canvas rules) · Embedding in Text Views
- MermaidLayout — Headless Layout (measurer injection, other backends, programmatic diagrams) · Scene Geometry and Linting · Adding a Diagram Type (the full five-file walkthrough)
Plus CONTRIBUTING.md for the review rules (geometry-first) and the most-wanted list.
Why is rendering Apple-only? The layout target already builds without AppKit/UIKit — only the CoreGraphics/CoreText drawing is platform-bound. An SVG backend over the scene IR would make the whole pipeline portable; it's the most-wanted contribution.
Why doesn't the output look exactly like mermaid.js? Deliberate. MermaidKit renders diagrams in a native Apple aesthetic (system fonts, your theme's colors) rather than pixel-cloning mermaid.js's default skin. Same structure, native skin.
Why macOS 14 / iOS 17? Those are the floors of the app MermaidKit was extracted from. Nothing fundamental blocks lower floors; it's tracked, and PRs verifying older OSes are welcome.
Is it safe to render untrusted input? That's the design point of the input caps, numeric sanitation, and the adversarial suite. No network, no JS, no dynamic code — parse and draw.
Open an issue with (1) the Mermaid source, (2) what you expected (a mermaid.live screenshot is perfect), (3) what MermaidKit did. Parser gaps are usually small, contained fixes — the per-type parser + layout + renderer files are deliberately independent.
MIT.