npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

canvasdown

v0.1.0

Published

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

Readme

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/nhoxtvt/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