Skip to content

Repository files navigation

canvasdown

Markdown renderer for Canvas. No DOM. No reflow. Runs anywhere.

npm version license TypeScript built on Pretext

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.


Why canvasdown?

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

Install

npm install canvasdown

Quick Start

import { 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 })

API Reference

Core

render(markdown, canvas, options?)

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(markdown, options?)

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`)

exportPNG(markdown, options?)

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',
})

exportBlob(markdown, options?)

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')

Streaming (for AI Chat)

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 unmount

Stream 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
}

Advanced Features

Import from canvasdown/advanced:

import { shrinkwrap, fitText, createOverlay, renderTextOnPath, animateText, updateRender } from 'canvasdown/advanced'

shrinkwrap(markdown, options?) — Auto-fit container

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 overflow

fitText(markdown, options?) — Shrink font to fit box

Find 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 fits

createOverlay(canvas, layout) — Semantic highlight

Add 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 element

renderTextOnPath(ctx, text, svgPath, options?) — Text along curves

Render 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).

animateText(canvas, markdown, animOptions, renderOptions?) — Per-character animation

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()

updateRender(prev, next, canvas, options?) — Diff-aware re-render

Re-render only what changed. Efficient for live preview / collaborative editors.

updateRender(oldMarkdown, newMarkdown, canvas, { width: 680, theme: 'dark' })

Node.js Support

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 binaries

React Component

import { 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>
  )
}

Web Worker + OffscreenCanvas

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)
  }
}

Markdown Support

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 ![alt](url) ✅ placeholder on load
Horizontal rule ---
CJK (Chinese/Japanese/Korean) 春天到了 ✅ via Pretext
Emoji 🎉🚀
Arabic / RTL بدأت الرحلة 🔜 planned
Syntax highlight ```ts 🔜 planned

Themes

Built-in themes

import { darkTheme, lightTheme } from 'canvasdown'

render(markdown, canvas, { theme: 'dark' })   // #0d1117 background
render(markdown, canvas, { theme: 'light' })  // #ffffff background

Custom theme

import 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 })

Use Cases

1. AI Chat with Streaming

// 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()

2. OG Image Generator (Next.js API Route)

// 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' },
  })
}

3. Virtual List with Exact Heights

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]} ... />

4. Text Animation for Hero Sections

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()

Development

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.html

Project Structure

src/
├── 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

Credits

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.


License

MIT © 2026 canvasdown contributors

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages