Skip to main content

Excalidraw vs tldraw vs Miro SDK: Collaborative Whiteboard Libraries (2026)

·PkgPulse Team

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

FeatureExcalidrawtldrawMiro SDK
TypeReact componentReact SDKPlatform SDK
Canvas styleHand-drawnClean/customMiro style
Self-hosted❌ (Miro platform)
Custom shapesVia elements API✅ (ShapeUtil)Via widgets
Custom toolsLimited✅ (full toolkit)Via apps
Real-time collab✅ (built-in)Via Yjs/custom✅ (built-in)
E2E encryptionCustomMiro manages
Export SVGVia API
Export PNGVia API
Infinite canvas
Pages/scenes✅ (frames)
White-labelPartial
REST API
Marketplace✅ (Miro apps)
Free tier✅ (MIT)✅ (free license)✅ (free plan)
LicenseMITtldraw licenseProprietary

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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.