Markdown renderer for Canvas. No DOM. No reflow. Runs anywhere.
canvasdown renders Markdown directly to HTML Canvas — no DOM, no layout reflow, no browser rendering pipeline. Powered by Pretext, which measures text ~300,000x faster than getBoundingClientRect.
Works in browser, Node.js, Web Workers, and WebGL contexts.
| react-markdown | Satori (Vercel) | canvasdown | |
|---|---|---|---|
| Input | Markdown | JSX | Markdown |
| Output | DOM (HTML) | SVG | Canvas / PNG |
| DOM reflow | Every render | N/A | Never |
| Streaming (AI chat) | Slow | No | Yes |
| Node.js | SSR only | Yes | Yes |
| Web Worker | No | No | Yes |
| Per-char animation | No | No | Yes |
| Text along path | No | No | Yes |
| Virtual list heights | Estimate | No | Exact |
| OG image generation | html2canvas | Yes | Yes |
npm install canvasdownimport { render, darkTheme } from 'canvasdown'
const markdown = `
# Hello canvasdown
Render **Markdown** to Canvas with *no DOM reflow*.
- Zero DOM reads
- Runs in Node.js
- Streams token-by-token
`
const canvas = document.getElementById('output') as HTMLCanvasElement
render(markdown, canvas, { width: 800, theme: darkTheme })Render markdown to a canvas element. Automatically sets canvas width/height and handles HiDPI.
import { render } from 'canvasdown'
render(markdown, canvas, {
width: 800, // canvas width in px (default: 800)
theme: 'dark', // 'dark' | 'light' | Theme object
devicePixelRatio: 2, // HiDPI (default: window.devicePixelRatio)
})Measure layout without rendering — get exact height before touching the canvas. Essential for virtual lists.
import { measure } from 'canvasdown'
const { totalHeight, blocks } = measure(markdown, { width: 680, theme: 'dark' })
console.log(`This markdown will be ${totalHeight}px tall`)Export as PNG dataURL. Works in browser and Node.js (with canvasFactory).
import { exportPNG } from 'canvasdown'
const dataURL = await exportPNG(markdown, {
width: 1200,
devicePixelRatio: 2, // @2x = 2400px wide PNG
theme: 'dark',
})Export as Blob for upload or download.
import { exportBlob } from 'canvasdown'
const blob = await exportBlob(markdown, { width: 1200 })
const formData = new FormData()
formData.append('image', blob, 'og-image.png')Stream markdown token-by-token with zero reflow. Built for LLM streaming responses.
import { createStream } from 'canvasdown/stream'
const canvas = document.getElementById('chat-canvas') as HTMLCanvasElement
const stream = createStream(canvas, { width: 680, theme: 'dark' })
// Connect to your LLM stream (OpenAI, Anthropic, etc.)
const response = await fetch('/api/chat', { method: 'POST', body: JSON.stringify({ prompt }) })
const reader = response.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
stream.append(decoder.decode(value)) // only re-paints changed lines
}
stream.flush() // finalize
stream.destroy() // cleanup on unmountStream API:
type StreamHandle = {
append(chunk: string): void // add text, schedules rAF render
flush(): void // immediate render, skip rAF
getLayout(): DocumentLayout | null // current layout state
destroy(): void // cancel pending rAF, cleanup
}Import from canvasdown/advanced:
import { shrinkwrap, fitText, createOverlay, renderTextOnPath, animateText, updateRender } from 'canvasdown/advanced'Find the tightest width that fits the text without overflow (binary search).
const { width, height } = shrinkwrap('Hello **world**', {
minWidth: 100,
maxWidth: 800,
theme: 'dark',
})
// width = smallest px where text doesn't overflowFind the largest font size where text fits within a container.
const { fontSize, lineHeight, height } = fitText(markdown, {
container: { width: 400, height: 200 },
fontSizeMin: 10,
fontSizeMax: 48,
theme: 'dark',
})
// fontSize = largest size that still fitsAdd highlight overlay without re-rendering the base canvas.
const layout = measure(markdown, { width: 680, theme: 'dark' })
render(markdown, canvas, { width: 680, theme: 'dark' })
const overlay = createOverlay(canvas, layout)
overlay.highlight('pretext', { background: '#ffff00', opacity: 0.4 })
overlay.highlight('canvas', { background: '#00ffff', opacity: 0.3 })
overlay.clear() // remove all highlights
overlay.destroy() // remove overlay elementRender text following any SVG path — not possible with DOM or SVG text.
renderTextOnPath(ctx, 'canvasdown ✦ canvas text along any curve', 'M 0 100 Q 200 0 400 100', {
font: '700 20px Inter',
color: '#f97316',
offset: 10, // start offset in px
})Supports SVG path commands: M, L, Q (quadratic), C (cubic bezier).
Animate individual characters with independent transforms.
const anim = animateText(canvas, markdown, {
effect: 'wave', // 'wave' | 'fadeIn' | 'typewriter' | 'bounce' | 'explode'
stagger: 30, // ms delay between characters
duration: 600, // ms per character animation
loop: false,
onComplete: () => console.log('done'),
})
anim.start()
anim.stop()
anim.reset()Re-render only what changed. Efficient for live preview / collaborative editors.
updateRender(oldMarkdown, newMarkdown, canvas, { width: 680, theme: 'dark' })Inject a canvas factory to use canvasdown server-side:
import { exportPNG } from 'canvasdown'
import { createCanvas } from '@napi-rs/canvas'
const png = await exportPNG(markdown, {
width: 1200,
devicePixelRatio: 2,
theme: 'dark',
canvasFactory: (w, h) => createCanvas(w, h),
})
// Save to file
import { writeFileSync } from 'fs'
const base64 = png.split(',')[1]!
writeFileSync('og-image.png', Buffer.from(base64, 'base64'))npm install @napi-rs/canvas # Rust-based, pre-built ARM64 binariesimport { createCanvasdownComponent } from 'canvasdown/react'
import React, { useState } from 'react'
const Canvasdown = createCanvasdownComponent(React)
function App() {
const [height, setHeight] = useState(0)
return (
<div style={{ position: 'relative' }}>
<Canvasdown
markdown={content}
width={680}
theme="dark"
className="chat-canvas"
onHeightChange={setHeight}
onRender={(layout) => console.log('blocks:', layout.blocks.length)}
/>
</div>
)
}Render off the main thread — keep UI at 60fps during heavy markdown rendering.
// main.ts
const worker = new Worker(new URL('./canvasdown.worker.js', import.meta.url))
const offscreen = canvas.transferControlToOffscreen()
worker.postMessage({ type: 'render', markdown, width: 800, theme: 'dark', dpr: 2 }, [offscreen])
worker.onmessage = (e) => console.log('rendered:', e.data.height, 'px')// canvasdown.worker.js
import { handleWorkerMessage } from 'canvasdown/worker'
let offscreen: OffscreenCanvas
self.onmessage = (e) => {
if (e.data.type === 'render') {
if (!offscreen) offscreen = e.ports[0] ?? e.data.canvas
const result = handleWorkerMessage(e.data, offscreen)
self.postMessage(result)
}
}| Element | Syntax | Status |
|---|---|---|
| Heading H1–H6 | # H1 … ###### H6 |
✅ |
| Paragraph | plain text | ✅ |
| Bold | **text** |
✅ |
| Italic | *text* |
✅ |
| Bold italic | ***text*** |
✅ |
| Inline code | `code` |
✅ |
| Link | [text](url) |
✅ underline + color |
| Strikethrough | ~~text~~ |
✅ |
| Code block | ```lang |
✅ |
| Blockquote | > text |
✅ |
| Unordered list | - item |
✅ |
| Ordered list | 1. item |
✅ |
| Nested list | indented items | ✅ |
| Task list | - [x] done |
✅ checkbox rendering |
| Table | | col | |
✅ |
| Image |  |
✅ placeholder on load |
| Horizontal rule | --- |
✅ |
| CJK (Chinese/Japanese/Korean) | 春天到了 | ✅ via Pretext |
| Emoji | 🎉🚀 | ✅ |
| Arabic / RTL | بدأت الرحلة | 🔜 planned |
| Syntax highlight | ```ts |
🔜 planned |
import { darkTheme, lightTheme } from 'canvasdown'
render(markdown, canvas, { theme: 'dark' }) // #0d1117 background
render(markdown, canvas, { theme: 'light' }) // #ffffff backgroundimport type { Theme } from 'canvasdown'
const myTheme: Theme = {
background: '#1a1b26',
text: '#c0caf5',
mutedText: '#565f89',
link: { color: '#7aa2f7' },
heading: {
color: '#c0caf5',
sizes: [36, 28, 22, 18, 16, 14],
weights: ['700', '600', '600', '600', '500', '500'],
lineHeights: [48, 38, 32, 28, 26, 24],
},
code: {
background: '#16161e',
color: '#c0caf5',
borderColor: '#292e42',
fontFamily: '"JetBrains Mono", monospace',
fontSize: 13,
lineHeight: 22,
borderRadius: 6,
padding: 16,
},
inlineCode: {
background: '#1f2335',
color: '#7aa2f7',
fontFamily: '"JetBrains Mono", monospace',
borderRadius: 4,
paddingH: 5,
},
blockquote: { borderColor: '#292e42', textColor: '#565f89', borderWidth: 4, paddingLeft: 16 },
table: {
headerBackground: '#16161e',
headerColor: '#c0caf5',
borderColor: '#292e42',
cellPadding: 12,
},
hr: { color: '#292e42' },
list: { bulletColor: '#565f89', indentX: 20, bulletGap: 8, itemGap: 4 },
image: { borderRadius: 6, maxHeight: 400 },
fontFamily: '"Inter", system-ui, sans-serif',
fontSize: 16,
lineHeight: 28,
padding: 48,
blockGap: 20,
}
render(markdown, canvas, { theme: myTheme })// Stream LLM response to canvas — no DOM reflow on every token
import { createStream } from 'canvasdown/stream'
const stream = createStream(canvas, { width: 680, theme: 'dark' })
for await (const chunk of llmStream) {
stream.append(chunk)
}
stream.flush()// app/api/og/route.ts
import { exportPNG } from 'canvasdown'
import { createCanvas } from '@napi-rs/canvas'
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const title = searchParams.get('title') ?? 'Hello'
const png = await exportPNG(`# ${title}\n\nGenerated with canvasdown`, {
width: 1200,
devicePixelRatio: 2,
theme: 'dark',
canvasFactory: (w, h) => createCanvas(w, h),
})
const buffer = Buffer.from(png.split(',')[1]!, 'base64')
return new Response(buffer, {
headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' },
})
}import { measure } from 'canvasdown'
// Pre-compute exact heights for ALL messages (no DOM, ~0.001ms each)
const heights = messages.map(msg =>
measure(msg.content, { width: 680, theme: 'dark' }).totalHeight
)
// Pass to virtual list — no estimates, no scroll jumps
<VirtualList itemCount={messages.length} itemHeight={i => heights[i]} ... />import { animateText } from 'canvasdown/advanced'
const anim = animateText(canvas, '# Welcome to the future', {
effect: 'wave',
stagger: 40,
duration: 800,
loop: true,
}, { width: 800, theme: 'dark' })
anim.start()git clone https://github.com/hoainho/canvasdown
cd canvasdown
npm install
npm run build # compile TypeScript → dist/
npm run typecheck # type checking only
node serve.cjs # serve demo at http://localhost:3847/demo/index.htmlsrc/
├── index.ts # public API: render, measure, exportPNG, exportBlob
├── parser.ts # markdown → RenderToken[] (via marked.js)
├── layout.ts # Pretext measurement → DocumentLayout
├── renderer.ts # DocumentLayout → Canvas 2D
├── theme.ts # Theme type + darkTheme + lightTheme
├── tokens.ts # TypeScript types for tokens
├── canvas-provider.ts # CanvasFactory abstraction (browser/Node/Worker)
├── stream.ts # streaming render API
├── advanced.ts # shrinkwrap, fitText, overlay, path text, animation
├── react.ts # React component factory
└── worker-bridge.ts # OffscreenCanvas + Web Worker bridge
demo/
└── index.html # live playground
canvasdown is built on top of Pretext by Cheng Lou — a pure JS/TS library for multiline text measurement that avoids DOM reflow.
The original architecture concept comes from text-layout by Sebastian Markbage.
MIT © 2026 canvasdown contributors