Excalidraw vs tldraw vs Miro SDK: Collaborative Whiteboard Libraries (2026)
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 |
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 →