A slimmer mermaid — SVG-first diagram renderer for mermaid-style syntax. 14 native diagram types, zero mermaid runtime dependency.
| Bundle | ~160 KB minified ESM (vs. mermaid's ~3 MB lazy-loaded / ~7 MB full) |
| Runtime deps | dagre only |
| Peer deps | react ^18 || ^19, react-dom ^18 || ^19 |
| Mermaid | none — parsers and renderers are native |
| Outputs | Standalone SVG, PNG (via canvas), and plain-text ASCII (Unicode box-drawing) |
| License | MIT |
Mermaid is great, but it's heavy, opaque, and not easy to extend. merslim re-implements the popular subset of mermaid syntax with:
- A small, typed intermediate representation (IR) you can build programmatically — no need to round-trip through text if you have structured data.
- A pluggable renderer registry. Diagrams are React components; lazy-loaded by type so you only pay for what you use.
- A serializer that walks the live DOM, inlines
getComputedStyle()values onto a clone, and emits a self-contained SVG that opens identically in browsers, Inkscape, and Office. - No vendor lock-in to a single visual style — every renderer is ~150–400 lines of plain React/SVG, easy to fork.
flowchart · sequenceDiagram · erDiagram · classDiagram · stateDiagram-v2 · gantt · timeline · pie · quadrantChart · journey · mindmap · architecture-beta · C4Context (Container / Component / Deployment) · gitGraph
npm install merslimimport { DiagramRenderer, bootstrapDiagramRenderers } from 'merslim';
bootstrapDiagramRenderers(); // call once at app startup
const source = `
flowchart LR
A[Edit] --> B{Render}
B --> C[Export]
`;
export function App() {
return <DiagramRenderer source={source} />;
}import { useRef } from 'react';
import {
DiagramRenderer,
DiagramExportToolbar,
type RendererHandle,
} from 'merslim';
export function MyDiagram({ source }: { source: string }) {
const handleRef = useRef<RendererHandle | null>(null);
return (
<div className="group relative">
<DiagramRenderer source={source} handleRef={handleRef} />
<DiagramExportToolbar
source={() => handleRef.current?.getSvgElement() ?? null}
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100"
/>
</div>
);
}The toolbar gives you four buttons — copy SVG, copy PNG, download SVG, download PNG — all routed through the same standalone-SVG serializer. Pass an optional asciiSource to add two more (copy ASCII, download .txt); see ASCII output below.
If you only need an SVG string (e.g. to generate diagrams at build time for an MDX blog), skip the React component entirely:
import { parseToIR, flowchartToSvg, type FlowchartIR } from 'merslim';
const result = await parseToIR(`
flowchart LR
A --> B
`);
if (result.ok && result.type === 'flowchart') {
const svg = flowchartToSvg(result.ir as FlowchartIR, { dark: false });
// write to disk, embed, ship to a CDN...
}Two builder flavors. For each graph-shaped diagram there's a one-call convenience builder (
flowchartToSvg,classToSvg,erToSvg) that runs layout internally, and a position-taking power-user builder (buildFlowchartSvg(ir, positions, opts)) for callers who want custom layout. Chart-shaped diagrams (pie, quadrant, journey, gantt, timeline, c4, architecture, gitgraph) are one-call already. Seeexamples/headless/generate.tsfor the full pattern.
Every one of the 14 diagram types also renders to plain text using Unicode box-drawing characters — useful for terminals, CI logs, code review comments, plain-text emails, and LLM tool outputs.
import { sourceToAscii } from 'merslim';
const text = await sourceToAscii(`
flowchart LR
A[Start] --> B --> C[End]
`);
console.log(text);
// ┌───────┐ ┌─────┐ ┌─────┐
// │ Start │────▶ B │────▶ End │
// └───────┘ └─────┘ └─────┘If you already have an IR (e.g. from parseToIR or hand-built), use the
synchronous asciiFromIR(ir) instead:
import { asciiFromIR, parseToIR } from 'merslim';
const result = await parseToIR(source);
if (result.ok) {
const text = asciiFromIR(result.ir);
}Per-type builders are also exported when you only need one:
buildFlowchartAscii, buildSequenceAscii, buildErAscii,
buildClassAscii, buildStateAscii, buildMindmapAscii,
buildGanttAscii, buildJourneyAscii, buildPieAscii,
buildTimelineAscii, buildQuadrantAscii, buildGitGraphAscii,
buildArchitectureAscii, buildC4Ascii.
Pass an asciiSource function to <DiagramExportToolbar/> to surface two
extra buttons ("ASCII" copy / ".TXT" download):
import {
DiagramRenderer,
DiagramExportToolbar,
asciiFromIR,
parseToIR,
type DiagramIR,
type RendererHandle,
} from 'merslim';
export function MyDiagram({ source }: { source: string }) {
const handleRef = useRef<RendererHandle | null>(null);
const [ir, setIr] = useState<DiagramIR | null>(null);
useEffect(() => {
parseToIR(source).then((r) => setIr(r.ok ? r.ir : null));
}, [source]);
return (
<div className="group relative">
<DiagramRenderer source={source} handleRef={handleRef} />
<DiagramExportToolbar
source={() => handleRef.current?.getSvgElement() ?? null}
asciiSource={() => (ir ? asciiFromIR(ir) : null)}
/>
</div>
);
}The parser is one way to produce an IR; you can produce one any way you like. If you have structured data (a list of orders, a service topology, a customer journey) you can skip mermaid syntax entirely:
import { flowchartToSvg, type FlowchartIR } from 'merslim';
const ir: FlowchartIR = {
type: 'flowchart',
direction: 'LR',
nodes: [
{ id: 'a', label: 'Order received', kind: 'start' },
{ id: 'b', label: 'Validate payment', kind: 'decision' },
{ id: 'c', label: 'Ship', kind: 'end' },
],
edges: [
{ source: 'a', target: 'b' },
{ source: 'b', target: 'c', label: 'paid' },
],
};
const svg = flowchartToSvg(ir, { dark: false });bootstrapDiagramRenderers() is a convenience that registers all 14 native
renderers (lazy-loaded, so unused ones stay out of your initial bundle).
If you only need a subset and want to skip even the lazy chunks, call
register() directly:
import { register, DiagramRenderer } from 'merslim';
register({
type: 'flowchart',
loader: () =>
import('merslim').then((m) => ({ default: m.FlowchartRenderer })),
});
// Now <DiagramRenderer/> only knows about flowcharts. Any other diagram type
// surfaces a "no renderer registered" error.If you already have an IR and want to skip the source-string parser entirely,
the 14 renderers are also exported directly and can be mounted as standalone
components — <FlowchartRenderer ir={ir} dark={dark}/>, <PieRenderer/>,
<SequenceRenderer/>, etc.
Pass a dark prop to <DiagramRenderer/>, or use the helper to track a .dark class on <html>:
import { isDarkMode, watchDarkMode } from 'merslim';
const [dark, setDark] = useState(isDarkMode);
useEffect(() => watchDarkMode(setDark), []);| Export | Purpose |
|---|---|
<DiagramRenderer source dark handleRef onError/> |
Parses a source string, dispatches to the matching renderer, exposes a RendererHandle ref. |
<DiagramExportToolbar source asciiSource filenameBase pngScale .../> |
Copy/download toolbar — 4 buttons by default (SVG/PNG copy + download), plus 2 more (ASCII copy + .txt download) when asciiSource is supplied. |
<FlowchartRenderer/> <SequenceRenderer/> <ERRenderer/> <ClassRenderer/> <StateRenderer/> <GanttRenderer/> <TimelineRenderer/> <PieRenderer/> <QuadrantRenderer/> <JourneyRenderer/> <MindmapRenderer/> <ArchitectureRenderer/> <C4Renderer/> <GitGraphRenderer/> |
Direct-mount renderer per diagram type. Same RendererProps<T> signature: { ir, dark, handleRef }. Use these when you already have an IR. |
| Export | Type | Purpose |
|---|---|---|
parseToIR(source) |
(string) => Promise<ParseResult> |
Mermaid syntax → typed IR. On success, narrows to { ok: true, type: DiagramType, ir: DiagramIR }. |
detectDiagramType(source) |
(string) => Promise<RecognizedDiagramType | null> |
Lightweight first-line check. Returns null for empty input, 'unsupported' for unrecognized headers. |
| Convenience (auto-layout) | Power-user (explicit positions) |
|---|---|
flowchartToSvg(ir, opts) |
buildFlowchartSvg(ir, positions, opts) |
classToSvg(ir, opts) |
buildClassSvg(ir, positions, opts) |
erToSvg(ir, opts) |
buildErSvg(ir, positions, opts) |
| — | buildStateSvg(ir, { topLevel, children }, opts) |
| — | buildMindmapSvg(ir, positions, opts) |
Plus the chart-shaped diagrams which never need positions:
buildPieSvg, buildQuadrantSvg, buildJourneySvg, buildGanttSvg,
buildTimelineSvg, buildArchitectureSvg, buildC4Svg, buildGitGraphSvg.
All builders take a final { dark, padding } options object and return a
self-contained SVG string with role="img" and an aria-label.
| Export | Type | Purpose |
|---|---|---|
sourceToAscii(source) |
(string) => Promise<string | null> |
One-call mermaid source → text. null only when parsing fails. |
asciiFromIR(ir) |
(DiagramIR) => string | null |
Synchronous IR → text dispatch. Covers all 14 diagram types. |
build{Type}Ascii(ir) |
(IR) => string |
Per-type builder. One per diagram type — same naming as the SVG builders. |
| Export | Purpose |
|---|---|
toSvgString(source, opts) |
Serialize any SvgSource to a standalone SVG string. |
svgToPngBlob(svg, opts) |
Rasterize an SVG string to a PNG Blob. |
downloadSvg / downloadPng / downloadText |
Trigger a file download (SVG, PNG, or plain text). |
copySvgToClipboard / copyPngToClipboard / copyTextToClipboard |
Write SVG (text), PNG (image), or arbitrary text to the clipboard. |
getSvgDimensions(svg) |
Best-effort intrinsic size from viewBox / attrs. |
| Export | Purpose |
|---|---|
register({ type, loader }) |
Register a renderer for a diagram type. |
bootstrapDiagramRenderers() |
One-shot registration of all built-in renderers. |
getRenderer(type) / hasRenderer(type) |
Introspect the registry. |
examples/playground/— Vite + React playground with all 14 diagram types, a live source editor, dark-mode toggle, and the export toolbar (SVG + PNG + ASCII).npm install && npm run dev(ornpm run buildfor a staticdist/that opens directly fromfile://).examples/headless/— Node script that emits one self-contained SVG and one Unicode-box-drawing.txtper diagram type.npm install && npm start.examples/react/App.tsx— Minimal React snippet showing the same component wiring (including ASCII toolbar buttons) without the playground chrome.
npm install
npm run type-check # tsc --noEmit
npm test # vitest run
npm run build # tsup → dist/ (ESM + CJS + .d.ts)- The built-in renderers use a handful of Tailwind utility classes for surrounding chrome (loading / error states). The diagrams themselves are pure SVG and render correctly without Tailwind; only the wrapper container styling looks bare. PRs to make this opt-out are welcome.
parseToIRis asynchronous because some builders (gantt, timeline) defer parsing work. Today the body is synchronous but the signature is stable.
merslim parses a curated subset of mermaid syntax. The full contract is enforced by test/compatCorpus.ts — every entry there is a parse-time test that runs in CI.
Known gaps (parse but produce a partial IR, or fail outright):
| Diagram | Gap |
|---|---|
| flowchart | Multi-target shorthand A & B --> C & D |
| flowchart | Trapezoid shape [/Foo\] (falls back to rect) |
| flowchart | <br/> in labels treated as literal text, not a line break |
| sequence | loop/alt/opt/par blocks parse but render flat (no visual nesting) |
| sequence | autonumber keyword accepted but not honored |
| sequence | Bidirectional <<->> arrow |
| class | Generics class Container~T~ |
| class | Cardinality labels "1" --> "*" |
| class | namespace { ... } blocks |
| state | Parallel/concurrent regions (-- separator) |
| state | <<choice>>/<<fork>>/<<join>> pseudo-states |
| gantt | excludes/todayMarker/tickInterval accepted but not modeled |
If you hit a case not listed here, add it to the corpus as a known-gap entry — that converts an issue into an executable spec.
MIT. See LICENSE.