Rendering mermaid diagrams as hand-drawn tldraw, at build time
Every diagram on a technical blog is a mermaid diagram, and every mermaid diagram looks like every other mermaid diagram. That's not a knock on mermaid. It's genuinely great that you can write A --> B in a code fence and get a flowchart. It's just that the output always looks like a tool drew it. Sharp rectangles, uniform strokes, the same blue-grey palette you've seen a thousand times.
Sunil Pai posted a fix I couldn't stop thinking about: render the mermaid through tldraw instead, so the same A --> B comes out in tldraw's warm, slightly-wobbly hand-drawn style. He wired it into his Astro blog and said steal it from this commit. So I did, and then I pulled it out into a framework-agnostic package, because the idea is too good to leave coupled to one site generator.
This is that package: remark-mermaid-tldraw. The diagram below is a real mermaid flowchart, rendered by the very pipeline this post describes.
The one idea: do it at build time
The naive way to put tldraw on a page is to ship tldraw to the browser. tldraw is a real editor: React, a canvas, a whole runtime. That's a lot of JavaScript to make a static picture of a flowchart that never changes.
So don't ship it. Render each diagram once, at build time, into an SVG, and serve the SVG. The reader downloads a few KB of vector paths and nothing else: no React, no mermaid, no tldraw, no hydration. The diagram is just an <img>.
That single decision is what makes this worth doing. Everything else is plumbing to make the plumbing invisible.
Two halves that never talk to each other
There are two moving parts, and the trick that makes them simple is that they don't share state. They just independently agree on a filename.
The remark plugin rewrites a ```mermaid fence into two <img> tags. The renderer produces the SVGs those tags point at. Neither imports the other. Neither reads a manifest. They both call the same hashMermaid(source) function and arrive at the same filename, so they line up by construction.
Here's the remark transform, barely longer than this paragraph:
import { visit } from "unist-util-visit";
import { hashMermaid } from "./shared.js";
export function remarkMermaidTldraw() {
return (tree) => {
visit(tree, "code", (node, index, parent) => {
if (node.lang !== "mermaid") return;
const hash = hashMermaid(node.value);
parent.children[index] = {
type: "html",
value:
`<figure class="mermaid-diagram not-prose">` +
`<img class="mermaid-light" src="/diagrams/${hash}.svg" alt="Diagram" />` +
`<img class="mermaid-dark" src="/diagrams/${hash}.dark.svg" alt="Diagram" />` +
`</figure>`,
};
});
};
}The filename is a hash of the source, and only the source. That detail matters more than it looks: it means the URL never changes when I change the rendering code. If I tweak the export padding or bump tldraw, the diagram re-renders but keeps its filename, so nothing 404s and no content cache desyncs. (Render-version changes are tracked by a marker comment written inside each SVG. A stale marker means re-render in place. The URL is sacred; the bytes behind it aren't.)
The renderer is a browser you never see
mermaid → tldraw conversion is @tldraw/mermaid's job. It's a real package that turns mermaid into native tldraw shapes. But it needs a live tldraw editor to run, and a live tldraw editor needs a DOM. So the renderer spins one up: a throwaway Vite dev server hosts a tiny tldraw harness, headless Chromium loads it, and we drive it over the page's window.
The export runs twice over the same shapes (once with tldraw's light theme, once with dark, both on a transparent background) so the page can pick a variant per color scheme. That's why every diagram here has a light twin you'll never see: this site is dark, so the CSS hides it. Flip the page to light and the black-on-white version shows instead, no re-render.
The bug that cost me an afternoon (it was Sunil's note that saved me)
tldraw sizes its text by measuring it in the DOM. If the tldraw_draw font hasn't loaded when a label gets measured, tldraw measures it against a fallback font, picks a box that's slightly too small, and then the real font loads and the text wraps and clips. You get diagrams with labels spilling out of their boxes, intermittently, depending on a font race.
The fix is to force the fonts in before rendering anything: drop a probe text shape for each font, wait for document.fonts.ready, then delete the probes. Four lines. Sunil had already hit this and left a comment about it in his version, which is the only reason I didn't spend a second afternoon on it.
const fonts = ["draw", "sans", "serif", "mono"];
for (const font of fonts) {
editor.createShape({ type: "text", props: { richText: toRichText("Mgjpqy"), font } });
}
await editor.fonts.loadRequiredFontsForCurrentPage();
await editor.getContainer().ownerDocument.fonts.ready;
editor.deleteShapes([...editor.getCurrentPageShapeIds()]);Using it
It's a remark plugin plus a render step, so it drops into anything that runs remark: Astro, Next, Eleventy, a plain Vite + MDX setup, the React Router site you're reading this on.
npm i -D remark-mermaid-tldraw tldraw @tldraw/mermaid playwright react react-dom
npx playwright install chromiumAdd the plugin to your markdown config:
import { remarkMermaidTldraw } from "remark-mermaid-tldraw";
// in your MDX / remark options
remarkPlugins: [remarkMermaidTldraw];Render the SVGs before you build, with a CLI for most setups, or an Astro integration that auto-renders and hot-reloads in dev:
remark-mermaid-tldraw --content "src/**/*.{md,mdx}" --out public/diagramsThen a few lines of CSS to pick a variant, and you write ```mermaid fences like you always have. Tall top-down flowchart crowding the column? Cap it from the fence: ```mermaid width=380. The README has the full options and per-framework recipes.
Where it falls back, and where it doesn't fit
This is the honest part. tldraw can't model every mermaid diagram type natively: pie charts, gantt, class, ER. For those, the renderer drops to mermaid's own SVG so you still get a real diagram, just not the hand-drawn look. Flowcharts and sequence-ish graphs are where this shines; that's most of what I draw anyway.
And it's build-time only. You need a browser available where you build, which means npx playwright install chromium in CI and a few extra seconds on a cold build. The flip side is the one I care about: the reader pays nothing. No runtime mermaid, no tldraw bundle, no layout shift while a diagram computes. Just an SVG that was drawn months ago and cached ever since.
Build-time cost, zero runtime cost. For diagrams on a blog, that's the trade I want every time.
The package is remark-mermaid-tldraw: MIT, framework-agnostic, standing entirely on Sunil's idea and @tldraw/mermaid's conversion. There's also a Claude Code skill that teaches your agent to wire it into a project in one shot, gotchas and all. If you put it on your own site, send me a diagram you're proud of.