10 min read
0%

tldraw

Back to Blog
tldraw

TLDRAW: Build Infinite Canvas Apps

tldraw is an open-source SDK for building infinite canvas experiences — whiteboards, diagram editors, collaborative drawing tools — without rebuilding the hard parts from scratch.

Installation

npm install tldraw

tldraw requires React 18+. It ships its own CSS that must be imported once:

import { Tldraw } from "tldraw";
import "tldraw/tldraw.css";

export default function App() {
  return <Tldraw />;
}

That’s a fully functional whiteboard with shapes, tools, pan/zoom, undo/redo, and selection. No config required.

The Editor API

The real power is the Editor instance. Access it via the onMount callback:

import { Tldraw, Editor } from "tldraw";

export default function App() {
  function handleMount(editor: Editor) {
    // Create a shape programmatically
    editor.createShape({
      type: "geo",
      x: 100,
      y: 100,
      props: {
        geo: "rectangle",
        w: 200,
        h: 120,
        text: "Hello tldraw",
      },
    });
  }

  return <Tldraw onMount={handleMount} />;
}

The editor is a reactive state machine. Every mutation goes through it, and every subscriber sees a consistent snapshot.

Custom Shapes

Define a shape type, a utility class, and a React component for rendering:

import {
  BaseBoxShapeUtil,
  Geometry2d,
  Rectangle2d,
  TLBaseShape,
} from "tldraw";

type CardShape = TLBaseShape<
  "card",
  { w: number; h: number; title: string }
>;

export class CardShapeUtil extends BaseBoxShapeUtil<CardShape> {
  static override type = "card" as const;

  getDefaultProps(): CardShape["props"] {
    return { w: 200, h: 120, title: "Untitled" };
  }

  getGeometry(shape: CardShape): Geometry2d {
    return new Rectangle2d({
      width: shape.props.w,
      height: shape.props.h,
      isFilled: true,
    });
  }

  component(shape: CardShape) {
    return (
      <div
        style={{
          width: shape.props.w,
          height: shape.props.h,
          background: "#1a1a2e",
          border: "2px solid #00f0ff",
          borderRadius: 8,
          padding: 12,
          color: "#e5e7eb",
          fontSize: 14,
        }}
      >
        {shape.props.title}
      </div>
    );
  }

  indicator(shape: CardShape) {
    return (
      <rect
        width={shape.props.w}
        height={shape.props.h}
        rx={8}
      />
    );
  }
}

Register it on the component:

const shapeUtils = [CardShapeUtil];

export default function App() {
  return <Tldraw shapeUtils={shapeUtils} />;
}

Persistence with Snapshots

Serialize the entire canvas state to JSON and restore it:

// Save
const snapshot = editor.getSnapshot();
localStorage.setItem("canvas", JSON.stringify(snapshot));

// Restore
const saved = localStorage.getItem("canvas");
if (saved) {
  editor.loadSnapshot(JSON.parse(saved));
}

Snapshots include all shapes, assets, camera position, and selection state.

Connecting to a Backend

The typical integration pattern: load a snapshot from your API on mount, then save debounced on every document change.

import { Tldraw, Editor, TLEditorSnapshot } from "tldraw";
import { useCallback, useRef } from "react";

async function loadCanvas(boardId: string): Promise<TLEditorSnapshot | null> {
  const res = await fetch(`/api/boards/${boardId}`);
  if (!res.ok) return null;
  return res.json();
}

async function saveCanvas(boardId: string, snapshot: TLEditorSnapshot) {
  await fetch(`/api/boards/${boardId}`, {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(snapshot),
  });
}

export default function Board({ boardId }: { boardId: string }) {
  const saveTimer = useRef<ReturnType<typeof setTimeout>>();

  const handleMount = useCallback(
    async (editor: Editor) => {
      // Load initial state
      const snapshot = await loadCanvas(boardId);
      if (snapshot) {
        editor.loadSnapshot(snapshot);
      }

      // Save on every document change, debounced
      editor.store.listen(
        () => {
          clearTimeout(saveTimer.current);
          saveTimer.current = setTimeout(() => {
            saveCanvas(boardId, editor.getSnapshot());
          }, 1000);
        },
        { scope: "document" },
      );
    },
    [boardId],
  );

  return <Tldraw onMount={handleMount} />;
}

scope: "document" filters out ephemeral state like cursor position and hover, so saves only fire when shapes actually change.

Reading Shape Data into Your App

You don’t have to work with raw snapshots. Query specific shapes and map them to your domain model:

function extractCards(editor: Editor) {
  return editor
    .getCurrentPageShapes()
    .filter((shape) => shape.type === "card")
    .map((shape) => ({
      id: shape.id,
      x: shape.x,
      y: shape.y,
      title: (shape.props as { title: string }).title,
    }));
}

editor.store.listen(
  () => {
    const cards = extractCards(editor);
    // Sync to your app state, e.g. React state, Zustand, etc.
    setCards(cards);
  },
  { scope: "document" },
);

Pushing External Data into tldraw

Go the other direction — write shapes from your app’s data into the canvas. Useful for loading structured data from a database:

function populateFromApi(editor: Editor, items: ApiItem[]) {
  // Remove any stale shapes of this type first
  const existing = editor
    .getCurrentPageShapes()
    .filter((s) => s.type === "card")
    .map((s) => s.id);
  editor.deleteShapes(existing);

  // Create fresh shapes from data
  editor.createShapes(
    items.map((item, i) => ({
      type: "card",
      x: (i % 4) * 240,
      y: Math.floor(i / 4) * 160,
      props: { w: 200, h: 120, title: item.name },
    })),
  );
}

Wrap mutations in editor.batch() to group them into a single undo step:

editor.batch(() => {
  editor.deleteShapes(staleIds);
  editor.createShapes(newShapes);
});

Store Subscriptions

React to any state change with store.listen:

editor.store.listen(
  ({ changes }) => {
    const updated = Object.values(changes.updated);
    console.log("shapes updated:", updated.length);
  },
  { scope: "document" },
);

Use scope: "document" to only listen to persistent shape changes (not ephemeral pointer position).

Bring Your Own Store

For full control over state initialization and syncing, create the store yourself and pass it in. This is the pattern tldraw’s multiplayer packages use internally:

import {
  createTLStore,
  defaultShapeUtils,
  Tldraw,
} from "tldraw";
import { useMemo, useState, useEffect } from "react";

export default function ControlledBoard({ initialSnapshot }) {
  const store = useMemo(
    () => createTLStore({ shapeUtils: defaultShapeUtils }),
    [],
  );

  const [ready, setReady] = useState(false);

  useEffect(() => {
    if (initialSnapshot) {
      store.loadSnapshot(initialSnapshot);
    }
    setReady(true);
  }, [store, initialSnapshot]);

  if (!ready) return <div>Loading...</div>;
  return <Tldraw store={store} />;
}

This decouples store creation from the component lifecycle — useful when you need to attach sync adapters, run migrations, or pre-populate the store before the canvas mounts.

Custom Tools

Extend StateNode to build a tool with its own pointer event handling:

import { StateNode, TLEventHandlers } from "tldraw";

export class StickyTool extends StateNode {
  static override id = "sticky";

  override onPointerDown: TLEventHandlers["onPointerDown"] = (info) => {
    const { currentPagePoint } = this.editor.inputs;
    this.editor.createShape({
      type: "note",
      x: currentPagePoint.x - 100,
      y: currentPagePoint.y - 100,
      props: { text: "📌" },
    });
  };
}

Register it alongside shapeUtils:

const tools = [StickyTool];

export default function App() {
  return <Tldraw shapeUtils={shapeUtils} tools={tools} />;
}

UI Overrides

Hide or replace the default toolbar, menu bar, or style panel:

const uiOverrides = {
  tools(editor, tools) {
    return {
      ...tools,
      sticky: {
        id: "sticky",
        icon: "sticky-note",
        label: "Sticky",
        kbd: "s",
        onSelect() {
          editor.setCurrentTool("sticky");
        },
      },
    };
  },
};

export default function App() {
  return (
    <Tldraw
      shapeUtils={shapeUtils}
      tools={tools}
      overrides={uiOverrides}
    />
  );
}

Multiplayer with Yjs

tldraw’s store is CRDTcompatible. Sync it across clients using @tldraw/yjs:

import { useSyncDemo } from "@tldraw/sync";

export default function CollabApp() {
  const store = useSyncDemo({ roomId: "my-room" });
  return <Tldraw store={store} />;
}

The useSyncDemo hook connects to tldraw’s hosted demo sync server — swap it for your own WebSocket backend using createTLStore + a Yjs provider.

When to Use tldraw

Good fit: whiteboard features inside an existing app, visual node editors, annotation layers, diagram builders, prototyping tools.

Not the right tool: general drawing apps where you need pixel-level control, or environments where React is not available.


Browser support snapshot

Live support matrix for pointer-events from Can I Use.

Show static fallback image Data on support for pointer-events across major browsers from caniuse.com

Source: caniuse.com

Canvas is not supported in your browser