TL;DR
Excalidraw is the virtual whiteboard for hand-drawn diagrams — open-source, React component, real-time collaboration, end-to-end encryption, SVG/PNG export, extensible. tldraw is the infinite canvas SDK — React library, shape system, custom tools, collaboration-ready, white-label, designed for embedding. Miro SDK is the collaborative whiteboard platform SDK — build apps on Miro's canvas, widgets, boards API, enterprise collaboration, marketplace. In 2026: Excalidraw for hand-drawn style whiteboards, tldraw for custom infinite canvas apps, Miro SDK for building on Miro's platform.
Key Takeaways
- Excalidraw: @excalidraw/excalidraw ~80K weekly downloads — hand-drawn, E2E encrypted
- tldraw: tldraw ~30K weekly downloads — infinite canvas SDK, custom shapes
- Miro SDK: @mirohq/websdk ~5K weekly downloads — Miro platform, board widgets
- Excalidraw provides the most recognizable hand-drawn diagram experience
- tldraw offers the most customizable canvas SDK for building products
- Miro SDK integrates with the largest enterprise whiteboard platform
Excalidraw
Excalidraw — hand-drawn whiteboard:
React component
// components/Whiteboard.tsx
import { Excalidraw } from "@excalidraw/excalidraw"
import { ExcalidrawElement } from "@excalidraw/excalidraw/types"
import { useState, useCallback } from "react"
export function Whiteboard() {
const [excalidrawAPI, setExcalidrawAPI] = useState<any>(null)
const handleChange = useCallback(
(elements: readonly ExcalidrawElement[], state: any) => {
// Save to backend on change:
console.log(`${elements.length} elements on canvas`)
},
[]
)
return (
<div style={{ height: "100vh" }}>
<Excalidraw
ref={(api) => setExcalidrawAPI(api)}
onChange={handleChange}
initialData={{
elements: [],
appState: {
viewBackgroundColor: "#ffffff",
currentItemFontFamily: 1,
},
}}
UIOptions={{
canvasActions: {
loadScene: true,
export: { saveFileToDisk: true },
saveAsImage: true,
},
}}
theme="light"
/>
</div>
)
}
Programmatic element creation
// Create elements programmatically:
import { convertToExcalidrawElements } from "@excalidraw/excalidraw"
function createDiagram(excalidrawAPI: any) {
const elements = convertToExcalidrawElements([
// Rectangle:
{
type: "rectangle",
x: 100,
y: 100,
width: 200,
height: 100,
strokeColor: "#1e1e1e",
backgroundColor: "#a5d8ff",
fillStyle: "hachure",
strokeWidth: 2,
roughness: 1,
},
// Text:
{
type: "text",
x: 130,
y: 135,
text: "Frontend",
fontSize: 24,
fontFamily: 1,
textAlign: "left",
},
// Arrow connecting elements:
{
type: "arrow",
x: 300,
y: 150,
width: 100,
height: 0,
strokeColor: "#1e1e1e",
strokeWidth: 2,
points: [
[0, 0],
[100, 0],
],
},
// Another rectangle:
{
type: "rectangle",
x: 400,
y: 100,
width: 200,
height: 100,
strokeColor: "#1e1e1e",
backgroundColor: "#b2f2bb",
fillStyle: "hachure",
},
{
type: "text",
x: 435,
y: 135,
text: "Backend",
fontSize: 24,
fontFamily: 1,
},
])
excalidrawAPI.updateScene({ elements })
}
// Export as SVG:
async function exportToSVG(excalidrawAPI: any) {
const elements = excalidrawAPI.getSceneElements()
const appState = excalidrawAPI.getAppState()
const svg = await excalidrawAPI.exportToSvg({
elements,
appState: {
...appState,
exportWithDarkMode: false,
exportBackground: true,
},
})
return svg.outerHTML
}
// Export as PNG:
async function exportToPNG(excalidrawAPI: any) {
const elements = excalidrawAPI.getSceneElements()
const blob = await excalidrawAPI.exportToBlob({
elements,
mimeType: "image/png",
quality: 1,
exportPadding: 20,
})
return blob
}
// Export as JSON (for saving/loading):
function saveScene(excalidrawAPI: any) {
const elements = excalidrawAPI.getSceneElements()
const appState = excalidrawAPI.getAppState()
const files = excalidrawAPI.getFiles()
return JSON.stringify({ elements, appState, files })
}
function loadScene(excalidrawAPI: any, sceneData: string) {
const { elements, appState, files } = JSON.parse(sceneData)
excalidrawAPI.updateScene({ elements, appState })
if (files) excalidrawAPI.addFiles(Object.values(files))
}
Collaboration
// Real-time collaboration with Excalidraw:
import { Excalidraw } from "@excalidraw/excalidraw"
import { useCollaboration } from "./hooks/useCollaboration"
export function CollaborativeWhiteboard({ roomId }: { roomId: string }) {
const {
excalidrawAPI,
setExcalidrawAPI,
collaborators,
onPointerUpdate,
} = useCollaboration(roomId)
return (
<div style={{ height: "100vh" }}>
<Excalidraw
ref={(api) => setExcalidrawAPI(api)}
isCollaborating={true}
onPointerUpdate={onPointerUpdate}
/>
<div className="collaborator-count">
{collaborators.size} users online
</div>
</div>
)
}
// hooks/useCollaboration.ts
// Collaboration implementation using WebSocket/WebRTC:
import { useState, useEffect, useCallback, useRef } from "react"
export function useCollaboration(roomId: string) {
const [excalidrawAPI, setExcalidrawAPI] = useState<any>(null)
const [collaborators, setCollaborators] = useState(new Map())
const wsRef = useRef<WebSocket | null>(null)
useEffect(() => {
const ws = new WebSocket(`wss://collab.example.com/rooms/${roomId}`)
wsRef.current = ws
ws.onmessage = (event) => {
const message = JSON.parse(event.data)
switch (message.type) {
case "scene-update":
excalidrawAPI?.updateScene({
elements: message.elements,
})
break
case "cursor-update":
setCollaborators((prev) => {
const next = new Map(prev)
next.set(message.userId, {
pointer: message.pointer,
username: message.username,
})
return next
})
break
}
}
return () => ws.close()
}, [roomId, excalidrawAPI])
const onPointerUpdate = useCallback(
(payload: { pointer: { x: number; y: number } }) => {
wsRef.current?.send(
JSON.stringify({
type: "cursor-update",
pointer: payload.pointer,
})
)
},
[]
)
return { excalidrawAPI, setExcalidrawAPI, collaborators, onPointerUpdate }
}
tldraw
tldraw — infinite canvas SDK:
Basic setup
// components/Canvas.tsx
import { Tldraw } from "tldraw"
import "tldraw/tldraw.css"
export function Canvas() {
return (
<div style={{ position: "fixed", inset: 0 }}>
<Tldraw />
</div>
)
}
// With persistence:
import { Tldraw, createTLStore, defaultShapeUtils } from "tldraw"
export function PersistentCanvas() {
const store = createTLStore({ shapeUtils: defaultShapeUtils })
// Load from localStorage:
const savedData = localStorage.getItem("canvas")
if (savedData) {
store.loadSnapshot(JSON.parse(savedData))
}
// Auto-save:
store.listen(() => {
const snapshot = store.getSnapshot()
localStorage.setItem("canvas", JSON.stringify(snapshot))
})
return (
<div style={{ position: "fixed", inset: 0 }}>
<Tldraw store={store} />
</div>
)
}
Custom shapes
// shapes/PackageCardShape.tsx
import {
ShapeUtil,
TLBaseShape,
HTMLContainer,
Rectangle2d,
defineShape,
} from "tldraw"
type PackageCardShape = TLBaseShape<
"package-card",
{
w: number
h: number
name: string
downloads: number
version: string
}
>
class PackageCardShapeUtil extends ShapeUtil<PackageCardShape> {
static override type = "package-card" as const
getDefaultProps(): PackageCardShape["props"] {
return {
w: 250,
h: 140,
name: "package",
downloads: 0,
version: "1.0.0",
}
}
getGeometry(shape: PackageCardShape) {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
})
}
component(shape: PackageCardShape) {
return (
<HTMLContainer>
<div
style={{
width: shape.props.w,
height: shape.props.h,
background: "#1e293b",
borderRadius: 12,
padding: 16,
color: "white",
fontFamily: "Inter, sans-serif",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}
>
<div>
<div style={{ fontSize: 18, fontWeight: 700 }}>
{shape.props.name}
</div>
<div style={{ fontSize: 13, color: "#94a3b8", marginTop: 4 }}>
v{shape.props.version}
</div>
</div>
<div style={{ fontSize: 24, fontWeight: 800, color: "#3b82f6" }}>
{shape.props.downloads.toLocaleString()}
<span style={{ fontSize: 12, color: "#64748b", marginLeft: 4 }}>
/week
</span>
</div>
</div>
</HTMLContainer>
)
}
indicator(shape: PackageCardShape) {
return (
<rect width={shape.props.w} height={shape.props.h} rx={12} ry={12} />
)
}
}
// Register custom shape:
const customShapes = [
defineShape("package-card", { util: PackageCardShapeUtil }),
]
Custom tools
// tools/PackageCardTool.tsx
import { StateNode, TLEventHandlers } from "tldraw"
class PackageCardTool extends StateNode {
static override id = "package-card"
override onPointerDown: TLEventHandlers["onPointerDown"] = (info) => {
const { currentPagePoint } = this.editor.inputs
this.editor.createShape({
type: "package-card",
x: currentPagePoint.x,
y: currentPagePoint.y,
props: {
name: "New Package",
downloads: 0,
version: "1.0.0",
},
})
this.editor.setCurrentTool("select")
}
}
// Full app with custom shapes and tools:
import { Tldraw } from "tldraw"
export function CustomCanvas() {
return (
<div style={{ position: "fixed", inset: 0 }}>
<Tldraw
shapeUtils={[PackageCardShapeUtil]}
tools={[PackageCardTool]}
overrides={{
tools(editor, tools) {
tools["package-card"] = {
id: "package-card",
icon: "package",
label: "Package Card",
kbd: "p",
onSelect: () => editor.setCurrentTool("package-card"),
}
return tools
},
}}
/>
</div>
)
}
Collaboration and export
// Real-time collaboration with tldraw:
import { Tldraw, useEditor } from "tldraw"
import { useYjsStore } from "./hooks/useYjsStore"
export function CollaborativeCanvas({ roomId }: { roomId: string }) {
const store = useYjsStore({ roomId })
return (
<div style={{ position: "fixed", inset: 0 }}>
<Tldraw store={store} />
</div>
)
}
// Export utilities:
function ExportControls() {
const editor = useEditor()
async function exportAsSVG() {
const shapeIds = editor.getCurrentPageShapeIds()
const svg = await editor.getSvg([...shapeIds])
if (svg) {
const svgString = new XMLSerializer().serializeToString(svg)
downloadFile(svgString, "canvas.svg", "image/svg+xml")
}
}
async function exportAsPNG() {
const shapeIds = editor.getCurrentPageShapeIds()
const blob = await editor.toImage([...shapeIds], { type: "png", scale: 2 })
downloadFile(blob, "canvas.png", "image/png")
}
function exportAsJSON() {
const snapshot = editor.store.getSnapshot()
const json = JSON.stringify(snapshot, null, 2)
downloadFile(json, "canvas.json", "application/json")
}
return (
<div className="export-controls">
<button onClick={exportAsSVG}>Export SVG</button>
<button onClick={exportAsPNG}>Export PNG</button>
<button onClick={exportAsJSON}>Export JSON</button>
</div>
)
}
// Programmatic shape creation:
function createArchitectureDiagram(editor: any) {
editor.createShapes([
{
type: "geo",
x: 100,
y: 100,
props: {
w: 200,
h: 100,
geo: "rectangle",
text: "Frontend\n(React)",
color: "blue",
fill: "semi",
},
},
{
type: "arrow",
props: {
start: { x: 300, y: 150 },
end: { x: 400, y: 150 },
},
},
{
type: "geo",
x: 400,
y: 100,
props: {
w: 200,
h: 100,
geo: "rectangle",
text: "API\n(Node.js)",
color: "green",
fill: "semi",
},
},
])
}
Miro SDK
Miro SDK — build on Miro's canvas:
App setup
npm install @mirohq/websdk-types
npx create-miro-app my-miro-app
// app.ts — Miro app entry point
import { OnlineUserInfo } from "@mirohq/websdk-types"
async function init() {
// Get current board info:
const boardInfo = await miro.board.getInfo()
console.log(`Board: ${boardInfo.id}`)
// Get current user:
const user: OnlineUserInfo = await miro.board.getUserInfo()
console.log(`User: ${user.name}`)
// Listen for selection changes:
miro.board.ui.on("selection:update", async (event) => {
const selectedItems = event.items
console.log(`Selected ${selectedItems.length} items`)
})
}
init()
Creating widgets
// Create shapes on the board:
async function createPackageDiagram() {
// Create a frame:
const frame = await miro.board.createFrame({
title: "Package Architecture",
x: 0,
y: 0,
width: 1200,
height: 800,
})
// Create sticky notes:
const frontend = await miro.board.createStickyNote({
content: "Frontend\nReact 19",
x: -300,
y: 0,
width: 200,
style: {
fillColor: "light_blue",
textAlign: "center",
},
})
const backend = await miro.board.createStickyNote({
content: "Backend\nNode.js",
x: 100,
y: 0,
width: 200,
style: {
fillColor: "light_green",
textAlign: "center",
},
})
const database = await miro.board.createStickyNote({
content: "Database\nPostgreSQL",
x: 100,
y: 300,
width: 200,
style: {
fillColor: "light_yellow",
textAlign: "center",
},
})
// Create connectors:
await miro.board.createConnector({
start: { item: frontend.id, position: { x: 1, y: 0.5 } },
end: { item: backend.id, position: { x: 0, y: 0.5 } },
style: {
strokeColor: "#1a1a2e",
strokeWidth: 2,
},
captions: [{ content: "REST API" }],
})
await miro.board.createConnector({
start: { item: backend.id, position: { x: 0.5, y: 1 } },
end: { item: database.id, position: { x: 0.5, y: 0 } },
captions: [{ content: "Prisma" }],
})
// Add to frame:
await frame.add(frontend, backend, database)
// Zoom to frame:
await miro.board.viewport.zoomTo(frame)
}
Board data and widgets
// Read board items:
async function analyzeBoard() {
// Get all items on the board:
const items = await miro.board.get()
const stats = {
stickyNotes: items.filter((i) => i.type === "sticky_note").length,
shapes: items.filter((i) => i.type === "shape").length,
connectors: items.filter((i) => i.type === "connector").length,
texts: items.filter((i) => i.type === "text").length,
frames: items.filter((i) => i.type === "frame").length,
total: items.length,
}
return stats
}
// Search and filter items:
async function findPackageCards() {
const stickyNotes = await miro.board.get({ type: "sticky_note" })
return stickyNotes.filter((note) =>
note.content.toLowerCase().includes("package")
)
}
// Create card with custom fields:
async function createPackageCard(pkg: {
name: string
downloads: number
version: string
}) {
const card = await miro.board.createCard({
title: pkg.name,
description: `v${pkg.version}\n${pkg.downloads.toLocaleString()} weekly downloads`,
x: 0,
y: 0,
style: {
cardTheme: "#4262ff",
},
fields: [
{ value: `v${pkg.version}`, tooltip: "Version" },
{ value: `${(pkg.downloads / 1000000).toFixed(1)}M`, tooltip: "Downloads" },
],
})
return card
}
REST API (server-side)
// Miro REST API — server-side board management:
const MIRO_TOKEN = process.env.MIRO_ACCESS_TOKEN!
const BOARD_ID = process.env.MIRO_BOARD_ID!
const headers = {
Authorization: `Bearer ${MIRO_TOKEN}`,
"Content-Type": "application/json",
}
// Get board info:
const board = await fetch(`https://api.miro.com/v2/boards/${BOARD_ID}`, {
headers,
}).then((r) => r.json())
// Create sticky note via API:
await fetch(`https://api.miro.com/v2/boards/${BOARD_ID}/sticky_notes`, {
method: "POST",
headers,
body: JSON.stringify({
data: {
content: "React\n25M downloads/week",
shape: "square",
},
style: {
fillColor: "light_blue",
textAlign: "center",
textAlignVertical: "middle",
},
position: { x: 0, y: 0 },
}),
})
// Get all items on a board:
const items = await fetch(
`https://api.miro.com/v2/boards/${BOARD_ID}/items?limit=50`,
{ headers }
).then((r) => r.json())
// Create frame:
await fetch(`https://api.miro.com/v2/boards/${BOARD_ID}/frames`, {
method: "POST",
headers,
body: JSON.stringify({
data: { title: "Sprint Planning", format: "custom" },
position: { x: 0, y: 0 },
geometry: { width: 1600, height: 900 },
}),
})
// Webhooks:
await fetch("https://api.miro.com/v2/webhooks/board_subscriptions", {
method: "POST",
headers,
body: JSON.stringify({
boardId: BOARD_ID,
callbackUrl: "https://api.example.com/webhooks/miro",
status: "enabled",
}),
})
Feature Comparison
| Feature | Excalidraw | tldraw | Miro SDK |
|---|---|---|---|
| Type | React component | React SDK | Platform SDK |
| Canvas style | Hand-drawn | Clean/custom | Miro style |
| Self-hosted | ✅ | ✅ | ❌ (Miro platform) |
| Custom shapes | Via elements API | ✅ (ShapeUtil) | Via widgets |
| Custom tools | Limited | ✅ (full toolkit) | Via apps |
| Real-time collab | ✅ (built-in) | Via Yjs/custom | ✅ (built-in) |
| E2E encryption | ✅ | Custom | Miro manages |
| Export SVG | ✅ | ✅ | Via API |
| Export PNG | ✅ | ✅ | Via API |
| Infinite canvas | ✅ | ✅ | ✅ |
| Pages/scenes | ❌ | ✅ | ✅ (frames) |
| White-label | Partial | ✅ | ❌ |
| REST API | ❌ | ❌ | ✅ |
| Marketplace | ❌ | ❌ | ✅ (Miro apps) |
| Free tier | ✅ (MIT) | ✅ (free license) | ✅ (free plan) |
| License | MIT | tldraw license | Proprietary |
Real-Time Collaboration Architecture
Building real-time collaboration on top of these canvas libraries requires careful selection of the conflict resolution mechanism. Excalidraw's collaboration model uses a simple broadcast approach — every change is broadcast to all room participants, and the last writer wins for concurrent edits. This works well for small teams but degrades under high concurrency where two users edit the same element simultaneously. Tldraw's recommended collaboration approach uses Yjs, a CRDT (Conflict-free Replicated Data Type) library that merges concurrent changes from multiple users without conflicts. The useYjsStore hook wraps tldraw's store with a Yjs document, enabling multi-user editing where concurrent moves and resizes of the same shape merge correctly rather than overwriting each other. For Miro SDK integrations, collaboration is handled entirely by Miro's platform — your app code simply reads and writes board state, and Miro resolves conflicts.
Performance with Large Canvases
Canvas rendering performance degrades when the number of elements exceeds the browser's rendering budget. Excalidraw uses a rough.js canvas renderer that redraws the entire visible viewport on every frame — performant for hundreds of elements but noticeably slow for diagrams with thousands of elements. Tldraw uses a hybrid approach with SVG for interactive elements and Canvas 2D for performance-critical rendering, combined with spatial indexing that only renders elements in the current viewport. For large architecture diagrams or flow charts with thousands of nodes, tldraw's rendering performance is significantly better than Excalidraw's. Both support camera panning and zooming, but tldraw's level-of-detail system simplifies small elements when zoomed out, reducing rendering complexity at high zoom levels.
Persistence and Data Architecture
Storing canvas state in production requires decisions about data format, update frequency, and conflict resolution. Excalidraw's scene data is a JSON array of element objects — each element has a unique id, position, dimensions, and style properties. This format is stable across versions and straightforward to store in PostgreSQL as JSONB or in Redis for real-time scenarios. Tldraw's store snapshot format is also JSON and supports incremental updates through the store's event subscription API — rather than saving the entire snapshot on every change, subscribe to store changes and save only the changed records. Debounce the save operation to 500ms to avoid excessive database writes during rapid user interaction. For Miro SDK apps, board state is managed by Miro — your app stores only references to Miro item IDs, not the canvas data itself.
Security and Access Control
Embedding a collaborative canvas in a multi-tenant application requires careful isolation of canvas data between users and organizations. Excalidraw's end-to-end encryption model uses a key in the URL fragment (never sent to the server) to encrypt scene data — only users with the link can decrypt the content. This is excellent for public sharing but limits server-side access control, since the server cannot read the encrypted content to enforce permissions. For applications requiring server-side access control (audit logs, admin access to all canvases), use tldraw with a server-side collaboration backend that validates user permissions before broadcasting changes. The Yjs WebSocket server can check the user's session before connecting them to a Yjs room, preventing unauthorized users from seeing or modifying the canvas state.
TypeScript Integration
All three libraries ship TypeScript declarations, but the completeness varies. Tldraw's TypeScript integration is the strongest — the library is written in TypeScript, and the ShapeUtil generic type enforces that the shape's props type matches the component and geometry implementations. Implementing PackageCardShapeUtil extends ShapeUtil<PackageCardShape> ensures the component() and getGeometry() methods receive the correctly typed shape object. Excalidraw's TypeScript declarations cover the main Excalidraw component props and the element types, but some of the utility function types require casting when working with the internal element representation. Miro SDK's @mirohq/websdk-types package provides types for all board item types and the miro.board API, enabling typed access to the canvas data in TypeScript Miro app code.
Undo and History Management
Undo/redo history is foundational to any canvas editing experience, and each library handles it differently. Excalidraw maintains an internal undo stack that the built-in toolbar exposes through the standard Ctrl+Z and Ctrl+Y keyboard shortcuts — the history is managed by the library and not directly accessible to host application code. If you need to programmatically undo or trigger undo in response to custom UI events, use excalidrawAPI.undo() and excalidrawAPI.redo() methods available on the API object. Tldraw's undo history is managed through the editor's command system — every mutation to the store is wrapped in a command that knows how to undo itself. Call editor.undo() and editor.redo() programmatically, and access the history stack via editor.history. Tldraw also supports marking the history as "squashable," which merges rapid successive changes (like dragging) into a single undo step rather than creating dozens of individual steps. For collaborative editing with Yjs, undo must be handled by Y.UndoManager rather than tldraw's built-in history, since the distributed CRDT model requires undo operations that are aware of remote changes.
Embedding Considerations and Licensing
The licensing models affect how you can embed these tools in commercial products. Excalidraw is MIT licensed, meaning you can embed it in any product — open source or commercial — without restrictions or licensing fees. However, the MIT license means Excalidraw has no contributor license agreement requirements, so you must evaluate any third-party contributions for license compatibility when building on top of the library. Tldraw uses a custom "tldraw license" that is free for non-commercial use but requires a commercial license for revenue-generating applications — verify your use case against the license terms before shipping a product built on tldraw. The commercial license unlocks white-labeling and removes the tldraw attribution requirement from the UI. Miro SDK's licensing is tied to Miro's platform subscription — your users must have active Miro accounts to interact with a Miro-powered canvas, making it unsuitable for anonymous or public canvas experiences. Budget for per-seat Miro costs when planning a product built on the Miro SDK rather than self-hosted alternatives.
When to Use Each
Use Excalidraw if:
- Want the recognizable hand-drawn whiteboard style
- Need an open-source (MIT) embeddable whiteboard component
- Building collaborative diagramming with end-to-end encryption
- Prefer a mature, well-known diagramming tool with a large community
Use tldraw if:
- Building a custom infinite canvas product or feature
- Need full control over shapes, tools, and canvas behavior
- Want to white-label a canvas experience in your app
- Building a collaborative design tool, flowchart builder, or diagram editor
Use Miro SDK if:
- Building integrations or apps for Miro's enterprise platform
- Need access to Miro's board management REST API
- Want to reach Miro's user base through the app marketplace
- Building workflow tools that integrate with existing Miro boards
Methodology
Download data from npm registry (weekly average, March 2026). Feature comparison based on @excalidraw/excalidraw v0.17.x, tldraw v2.x, and @mirohq/websdk v2.x.
Compare whiteboard tools and developer libraries on PkgPulse →
See also: React vs Vue and React vs Svelte, Builder.io vs Plasmic vs Makeswift.