tldraw - Infinite Canvas Whiteboard dla Developerów
Czym jest tldraw?
tldraw to darmowy, w pełni open-source infinite canvas whiteboard, który można używać bezpośrednio w przeglądarce lub osadzić w aplikacjach React. Projekt został stworzony przez Steve'a Rubina i szybko zyskał popularność wśród deweloperów, designerów i zespołów szukających prostego, ale potężnego narzędzia do wizualizacji myśli, tworzenia diagramów i współpracy w czasie rzeczywistym.
W odróżnieniu od komercyjnych narzędzi jak Miro czy Figma, tldraw jest całkowicie darmowy i nie wymaga rejestracji. Wchodzisz na stronę, rysujesz i możesz natychmiast udostępnić link współpracownikom. Co więcej, jako biblioteka React, tldraw może być łatwo zintegrowany z własnymi aplikacjami - od narzędzi do dokumentacji, przez systemy zarządzania projektami, po edukacyjne platformy.
Szczególnie interesującą funkcją jest Make Real - integracja z AI, która pozwala narysować wireframe na tablicy, a następnie automatycznie wygenerować działający HTML/CSS na podstawie szkicu. To pokazuje potencjał tldraw jako narzędzia nie tylko do planowania, ale także do prototypowania.
Użycie online
Wejdź na tldraw.com i zacznij rysować - bez rejestracji, bez logowania, od razu.
Dlaczego tldraw?
Kluczowe zalety
- 100% darmowy - Open-source, Apache 2.0 License
- Bez rejestracji - Natychmiastowy dostęp do tablicy
- Infinite canvas - Nieograniczona przestrzeń do rysowania
- Real-time collaboration - Współpraca wielu osób na żywo
- Osadzanie w React - Łatwa integracja z aplikacjami
- Eksport - PNG, SVG, JSON, copy as image
- Multiplayer - Udostępnianie linków, cursor presence
- Customizacja - Rozszerzalne API dla własnych narzędzi
- Make Real - AI generuje kod z wireframów
- Self-hosting - Możliwość hostowania na własnym serwerze
tldraw vs konkurencja
| Cecha | tldraw | Excalidraw | Miro | Figma |
|---|---|---|---|---|
| Cena | Darmowy | Darmowy | Freemium | Freemium |
| Open-source | ✅ Apache 2.0 | ✅ MIT | ❌ | ❌ |
| Styl | Clean/Smooth | Hand-drawn | Polished | Professional |
| React library | ✅ Pełna | ✅ Podstawowa | ❌ | ❌ |
| Multiplayer | ✅ | ✅ | ✅ | ✅ |
| Self-hosting | ✅ | ✅ | ❌ | ❌ |
| AI integration | ✅ Make Real | ❌ | Ograniczone | ✅ AI features |
| Bez rejestracji | ✅ | ✅ | ❌ | ❌ |
| Customizacja | Wysoka | Średnia | Niska | Niska |
Funkcje i narzędzia
Narzędzia rysowania
┌─────────────────────────────────────────────────────────┐
│ TOOLBAR │
├─────────────────────────────────────────────────────────┤
│ 🔲 Select - Zaznaczanie i przesuwanie elementów │
│ ✏️ Draw - Rysowanie odręczne │
│ ↗️ Arrow - Strzałki z automatycznymi połączeniami │
│ ⬜ Rectangle - Prostokąty i kwadraty │
│ ⭕ Ellipse - Elipsy i okręgi │
│ △ Triangle - Trójkąty │
│ ⬡ Polygon - Wielokąty │
│ ⬢ Diamond - Romby │
│ ╱ Line - Linie proste │
│ A Text - Tekst │
│ 📝 Note - Sticky notes │
│ 🖼️ Image - Obrazy │
│ 📦 Frame - Ramki do grupowania │
│ ✂️ Crop - Przycinanie obrazów │
│ 🎨 Highlight - Zakreślacz │
│ 🔗 Embed - Osadzanie zewnętrznych treści │
└─────────────────────────────────────────────────────────┘Właściwości elementów
┌─────────────────────────────────────────────────────────┐
│ STYLE OPTIONS │
├─────────────────────────────────────────────────────────┤
│ Color: 🔵 Blue 🟢 Green 🔴 Red 🟡 Yellow ⚫ Black │
│ Fill: None | Solid | Pattern | Semi │
│ Dash: Solid | Dashed | Dotted │
│ Size: S | M | L | XL │
│ Font: Sans | Serif | Mono | Draw │
│ Opacity: 0% ────────────────────────── 100% │
└─────────────────────────────────────────────────────────┘Skróty klawiszowe
# Nawigacja
Space + Drag # Przesuwanie widoku (pan)
Scroll # Zoom
Ctrl/Cmd + 0 # Zoom to fit
Ctrl/Cmd + 1 # Zoom 100%
Ctrl/Cmd + 2 # Zoom to selection
# Narzędzia
V # Select
D # Draw
A # Arrow
R # Rectangle
O # Ellipse
T # Text
N # Note
F # Frame
# Akcje
Ctrl/Cmd + Z # Undo
Ctrl/Cmd + Y # Redo
Ctrl/Cmd + C # Copy
Ctrl/Cmd + V # Paste
Ctrl/Cmd + D # Duplicate
Delete # Usuń
Ctrl/Cmd + G # Grupuj
Ctrl/Cmd + Shift + G # Rozgrupuj
# Eksport
Ctrl/Cmd + Shift + C # Copy as PNG
Ctrl/Cmd + Shift + E # ExportWspółpraca
┌─────────────────────────────────────────────────────────┐
│ MULTIPLAYER FEATURES │
├─────────────────────────────────────────────────────────┤
│ 🔗 Share Link - Generuj link do współpracy │
│ 👥 Cursors - Widzisz kursory innych osób │
│ 💬 Comments - Dodawaj komentarze │
│ 👤 Presence - Lista osób na tablicy │
│ 🔒 Read-only - Linki tylko do odczytu │
│ 📱 Mobile - Działa na telefonach │
└─────────────────────────────────────────────────────────┘
# Udostępnianie
1. Kliknij "Share" w prawym górnym rogu
2. Skopiuj link
3. Wyślij współpracownikom
4. Real-time collaboration!Osadzanie w React
Podstawowa instalacja
npm install tldraw
# lub
pnpm add tldraw
# lub
yarn add tldrawPodstawowy komponent
// components/Whiteboard.tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export function Whiteboard() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw />
</div>
)
}Z persystencją
// components/PersistentWhiteboard.tsx
import { Tldraw, createTLStore, defaultShapeUtils, TLDocument } from 'tldraw'
import 'tldraw/tldraw.css'
import { useEffect, useState } from 'react'
const STORAGE_KEY = 'my-whiteboard'
export function PersistentWhiteboard() {
const [store] = useState(() => {
const store = createTLStore({ shapeUtils: defaultShapeUtils })
// Załaduj zapisany stan
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const snapshot = JSON.parse(saved)
store.loadSnapshot(snapshot)
}
return store
})
useEffect(() => {
// Zapisuj zmiany
const unsubscribe = store.listen(() => {
const snapshot = store.getSnapshot()
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot))
})
return unsubscribe
}, [store])
return (
<div style={{ height: '100vh' }}>
<Tldraw store={store} />
</div>
)
}Customowe narzędzia
// components/CustomToolWhiteboard.tsx
import {
Tldraw,
TLUiOverrides,
TLUiActionsContextType,
DefaultToolbar,
useTools,
TldrawUiMenuItem
} from 'tldraw'
import 'tldraw/tldraw.css'
// Własne narzędzie - stempel
const stampTool = {
id: 'stamp',
icon: 'stamp-icon',
label: 'Stamp',
onSelect: (editor: any) => {
// Logika narzędzia
}
}
// Override UI
const uiOverrides: TLUiOverrides = {
tools: (editor, tools) => {
return {
...tools,
stamp: {
id: 'stamp',
icon: 'stamp',
label: 'Stamp',
kbd: 's',
onSelect: () => {
editor.setCurrentTool('stamp')
}
}
}
},
actions: (editor, actions) => {
return {
...actions,
'clear-all': {
id: 'clear-all',
label: 'Clear All',
kbd: 'shift+c',
onSelect: () => {
editor.deleteAll()
}
}
}
}
}
// Własny toolbar
function CustomToolbar() {
const tools = useTools()
return (
<DefaultToolbar>
<TldrawUiMenuItem {...tools['select']} />
<TldrawUiMenuItem {...tools['draw']} />
<TldrawUiMenuItem {...tools['arrow']} />
<TldrawUiMenuItem {...tools['rectangle']} />
<TldrawUiMenuItem {...tools['text']} />
{/* Własny przycisk */}
<TldrawUiMenuItem {...tools['stamp']} />
</DefaultToolbar>
)
}
export function CustomToolWhiteboard() {
return (
<div style={{ height: '100vh' }}>
<Tldraw
overrides={uiOverrides}
components={{
Toolbar: CustomToolbar
}}
/>
</div>
)
}Własne kształty (Custom Shapes)
// shapes/StarShape.tsx
import {
BaseBoxShapeUtil,
Geometry2d,
HTMLContainer,
RecordProps,
T,
TLBaseShape,
Polygon2d,
Vec,
} from 'tldraw'
// Definicja typu kształtu
type IStarShape = TLBaseShape<
'star',
{
w: number
h: number
points: number
color: string
}
>
// Walidacja props
const starShapeProps: RecordProps<IStarShape> = {
w: T.number,
h: T.number,
points: T.number,
color: T.string,
}
// Utility do generowania punktów gwiazdy
function getStarPoints(cx: number, cy: number, spikes: number, outerRadius: number, innerRadius: number) {
const points: Vec[] = []
const step = Math.PI / spikes
for (let i = 0; i < 2 * spikes; i++) {
const radius = i % 2 === 0 ? outerRadius : innerRadius
const angle = i * step - Math.PI / 2
points.push(new Vec(cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)))
}
return points
}
// Shape Util
export class StarShapeUtil extends BaseBoxShapeUtil<IStarShape> {
static override type = 'star' as const
static override props = starShapeProps
override getDefaultProps(): IStarShape['props'] {
return {
w: 100,
h: 100,
points: 5,
color: '#ffd700',
}
}
override getGeometry(shape: IStarShape): Geometry2d {
const points = getStarPoints(
shape.props.w / 2,
shape.props.h / 2,
shape.props.points,
Math.min(shape.props.w, shape.props.h) / 2,
Math.min(shape.props.w, shape.props.h) / 4
)
return new Polygon2d({ points, isFilled: true })
}
override component(shape: IStarShape) {
const points = getStarPoints(
shape.props.w / 2,
shape.props.h / 2,
shape.props.points,
Math.min(shape.props.w, shape.props.h) / 2,
Math.min(shape.props.w, shape.props.h) / 4
)
const pathData = points
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`)
.join(' ') + ' Z'
return (
<HTMLContainer>
<svg width={shape.props.w} height={shape.props.h}>
<path
d={pathData}
fill={shape.props.color}
stroke="black"
strokeWidth={2}
/>
</svg>
</HTMLContainer>
)
}
override indicator(shape: IStarShape) {
const points = getStarPoints(
shape.props.w / 2,
shape.props.h / 2,
shape.props.points,
Math.min(shape.props.w, shape.props.h) / 2,
Math.min(shape.props.w, shape.props.h) / 4
)
const pathData = points
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`)
.join(' ') + ' Z'
return <path d={pathData} />
}
}
// Użycie
import { Tldraw, defaultShapeUtils } from 'tldraw'
import { StarShapeUtil } from './shapes/StarShape'
const customShapeUtils = [...defaultShapeUtils, StarShapeUtil]
function App() {
return (
<Tldraw shapeUtils={customShapeUtils} />
)
}Multiplayer z Yjs
// components/MultiplayerWhiteboard.tsx
import { Tldraw, useSync } from 'tldraw'
import { useSyncDemo } from '@tldraw/sync'
import 'tldraw/tldraw.css'
export function MultiplayerWhiteboard({ roomId }: { roomId: string }) {
// Użyj tldraw sync demo server
const store = useSyncDemo({
roomId,
userPreferences: {
name: 'User',
color: '#ff0000',
}
})
return (
<div style={{ height: '100vh' }}>
<Tldraw
store={store}
autoFocus
/>
</div>
)
}// Własny backend z Yjs
import { Tldraw, TLStore, TLStoreWithStatus } from 'tldraw'
import { useYjsStore } from './useYjsStore'
function useYjsStore(roomId: string): TLStoreWithStatus {
// Implementacja Yjs synchronizacji
// Zobacz dokumentację tldraw dla szczegółów
}
export function CustomMultiplayerWhiteboard({ roomId }: { roomId: string }) {
const storeWithStatus = useYjsStore(roomId)
return (
<div style={{ height: '100vh' }}>
<Tldraw store={storeWithStatus} />
</div>
)
}Make Real - AI Integration
Jak działa Make Real
┌─────────────────────────────────────────────────────────┐
│ MAKE REAL WORKFLOW │
├─────────────────────────────────────────────────────────┤
│ 1. Narysuj wireframe na tablicy │
│ ┌─────────────────────┐ │
│ │ [Logo] Nav Nav │ │
│ │ │ │
│ │ Hero Section │ │
│ │ [ Button ] │ │
│ │ │ │
│ └─────────────────────┘ │
│ │
│ 2. Zaznacz elementy │
│ │
│ 3. Kliknij "Make Real" │
│ │
│ 4. AI generuje HTML/CSS/JS │
│ ```html │
│ <nav class="flex justify-between p-4"> │
│ <div class="logo">Logo</div> │
│ <div class="nav-links">...</div> │
│ </nav> │
│ ... │
│ ``` │
│ │
│ 5. Podgląd w przeglądarce! │
└─────────────────────────────────────────────────────────┘Implementacja Make Real
// Własna implementacja Make Real
import { Tldraw, Editor, TLShapeId } from 'tldraw'
import Anthropic from '@anthropic-ai/sdk'
const anthropic = new Anthropic()
async function makeReal(editor: Editor, selectedIds: TLShapeId[]) {
// 1. Eksportuj zaznaczenie jako obraz
const blob = await editor.exportShapes(selectedIds, {
type: 'png',
scale: 2,
})
// 2. Konwertuj na base64
const base64 = await blobToBase64(blob)
// 3. Wyślij do Claude z promptem
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-5-20241022',
max_tokens: 4096,
messages: [{
role: 'user',
content: [
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: base64
}
},
{
type: 'text',
text: `Przekształć ten wireframe w kod HTML z Tailwind CSS.
Zwróć tylko kod HTML, bez wyjaśnień.
Kod powinien być responsywny i semantyczny.`
}
]
}]
})
// 4. Wyodrębnij HTML z odpowiedzi
const html = extractCode(response.content[0].text)
// 5. Wyświetl preview
openPreview(html)
}
// Dodaj przycisk do tldraw
const uiOverrides = {
actions: (editor: Editor, actions: any) => ({
...actions,
'make-real': {
id: 'make-real',
label: 'Make Real',
kbd: 'shift+m',
onSelect: () => {
const selectedIds = editor.getSelectedShapeIds()
if (selectedIds.length > 0) {
makeReal(editor, selectedIds)
}
}
}
})
}Self-hosting
Docker
# docker-compose.yml
version: '3.8'
services:
tldraw:
image: tldraw/tldraw:latest
ports:
- "3000:3000"
environment:
- NODE_ENV=production
volumes:
- tldraw_data:/app/data
volumes:
tldraw_data:# Uruchomienie
docker-compose up -d
# Dostęp
# http://localhost:3000Własna instancja z Next.js
# Stwórz projekt Next.js z tldraw
npx create-next-app@latest my-tldraw --typescript
cd my-tldraw
npm install tldraw// app/page.tsx
'use client'
import dynamic from 'next/dynamic'
const Tldraw = dynamic(
async () => (await import('tldraw')).Tldraw,
{ ssr: false }
)
import 'tldraw/tldraw.css'
export default function Home() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw />
</div>
)
}Backend z Cloudflare Workers
// workers/tldraw-sync.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
const app = new Hono()
app.use('*', cors())
// Przechowywanie dokumentów
const documents = new Map<string, any>()
app.get('/room/:id', async (c) => {
const id = c.req.param('id')
const doc = documents.get(id) || { shapes: [], bindings: [] }
return c.json(doc)
})
app.put('/room/:id', async (c) => {
const id = c.req.param('id')
const data = await c.req.json()
documents.set(id, data)
return c.json({ success: true })
})
export default appEksport i import
Programistyczny eksport
import { Tldraw, Editor } from 'tldraw'
function ExportButtons({ editor }: { editor: Editor }) {
const exportPng = async () => {
const shapeIds = editor.getCurrentPageShapeIds()
const blob = await editor.exportShapes([...shapeIds], {
type: 'png',
scale: 2,
background: true
})
// Download
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'whiteboard.png'
a.click()
}
const exportSvg = async () => {
const shapeIds = editor.getCurrentPageShapeIds()
const svg = await editor.exportShapesSvg([...shapeIds], {
background: true
})
const blob = new Blob([svg.innerHTML], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'whiteboard.svg'
a.click()
}
const exportJson = () => {
const snapshot = editor.store.getSnapshot()
const json = JSON.stringify(snapshot, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'whiteboard.json'
a.click()
}
return (
<div className="export-buttons">
<button onClick={exportPng}>Export PNG</button>
<button onClick={exportSvg}>Export SVG</button>
<button onClick={exportJson}>Export JSON</button>
</div>
)
}Import z JSON
function ImportButton({ editor }: { editor: Editor }) {
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const text = await file.text()
const snapshot = JSON.parse(text)
editor.store.loadSnapshot(snapshot)
}
return (
<input
type="file"
accept=".json"
onChange={handleImport}
/>
)
}Use Cases
Dokumentacja techniczna
// Embed w dokumentacji (np. Docusaurus)
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export function DiagramEmbed({ snapshotUrl }: { snapshotUrl: string }) {
const [snapshot, setSnapshot] = useState(null)
useEffect(() => {
fetch(snapshotUrl)
.then(r => r.json())
.then(setSnapshot)
}, [snapshotUrl])
if (!snapshot) return <div>Loading...</div>
return (
<div style={{ height: 400, border: '1px solid #ccc' }}>
<Tldraw
snapshot={snapshot}
hideUi
// Tylko podgląd, bez edycji
/>
</div>
)
}Brainstorming z zespołem
// Pokój do burzy mózgów
export function BrainstormRoom({ teamId }: { teamId: string }) {
const store = useSyncDemo({
roomId: `brainstorm-${teamId}`,
userPreferences: {
name: currentUser.name,
color: currentUser.color
}
})
return (
<div className="h-screen">
<header className="p-4 bg-gray-100 flex justify-between">
<h1>Team Brainstorm</h1>
<ParticipantsList />
</header>
<div className="h-[calc(100vh-60px)]">
<Tldraw store={store} />
</div>
</div>
)
}Edukacja online
// Tablica nauczyciela z możliwością nagrywania
export function TeacherWhiteboard() {
const editorRef = useRef<Editor | null>(null)
const [isRecording, setIsRecording] = useState(false)
const [history, setHistory] = useState<any[]>([])
const startRecording = () => {
setIsRecording(true)
// Zapisuj każdą zmianę
editorRef.current?.store.listen((update) => {
setHistory(h => [...h, { ...update, timestamp: Date.now() }])
})
}
const playback = () => {
// Odtwórz nagranie
history.forEach((update, i) => {
setTimeout(() => {
editorRef.current?.store.applyDiff(update)
}, update.timestamp - history[0].timestamp)
})
}
return (
<div>
<div className="controls">
<button onClick={startRecording}>
{isRecording ? '⏹ Stop' : '⏺ Record'}
</button>
<button onClick={playback}>▶ Playback</button>
</div>
<Tldraw
onMount={(editor) => { editorRef.current = editor }}
/>
</div>
)
}Cennik
| Plan | Cena | Zawiera |
|---|---|---|
| Open Source | $0 | Pełna biblioteka, self-hosting, unlimited |
| tldraw.com | $0 | Darmowa tablica online, multiplayer |
| Enterprise | Kontakt | Dedykowane wsparcie, SLA, custom features |
tldraw jest 100% darmowy na licencji Apache 2.0.
FAQ - Najczęściej zadawane pytania
Czy mogę używać tldraw komercyjnie?
Tak, licencja Apache 2.0 pozwala na komercyjne użycie bez ograniczeń.
Jak zapisać stan tablicy?
Użyj editor.store.getSnapshot() do eksportu i editor.store.loadSnapshot() do importu. Możesz zapisać w localStorage, bazie danych lub wysłać na serwer.
Czy tldraw działa offline?
Tak, podstawowa funkcjonalność działa offline. Dla multiplayer potrzebujesz połączenia.
Jak dodać własne kształty?
Stwórz klasę rozszerzającą BaseBoxShapeUtil lub BaseShapeUtil, zdefiniuj type, props, component i indicator, a następnie przekaż do shapeUtils.
Czy tldraw obsługuje touch/stylus?
Tak, tldraw ma pełne wsparcie dla touch, stylus i pressure sensitivity (nacisk pióra).
Jak zintegrować z backendem?
Użyj oficjalnego @tldraw/sync lub zaimplementuj własną synchronizację z Yjs, WebSocket lub REST API.
Czy mogę ukryć UI?
Tak, użyj hideUi prop lub components prop do selektywnego ukrywania elementów.
Podsumowanie
tldraw to potężne, darmowe narzędzie do wizualizacji:
- 100% open-source - Apache 2.0, bez ukrytych kosztów
- Infinite canvas - Nieograniczona przestrzeń do rysowania
- React library - Łatwa integracja z aplikacjami
- Real-time multiplayer - Współpraca w czasie rzeczywistym
- Make Real - AI generuje kod z wireframów
- Customizable - Własne narzędzia, kształty, UI
- Self-hostable - Pełna kontrola nad danymi
tldraw to idealny wybór dla teamów szukających darmowej, rozszerzalnej tablicy do diagramów, wireframów i współpracy.
tldraw - Infinite Canvas Whiteboard for developers
What is tldraw?
tldraw is a free, fully open-source infinite canvas whiteboard that you can use directly in the browser or embed in React applications. The project was created by Steve Rubin and quickly gained popularity among developers, designers, and teams looking for a simple yet powerful tool for visualizing ideas, creating diagrams, and collaborating in real time.
Unlike commercial tools such as Miro or Figma, tldraw is completely free and requires no registration. You visit the website, start drawing, and can immediately share the link with collaborators. Moreover, as a React library, tldraw can be easily integrated into your own applications - from documentation tools, through project management systems, to educational platforms.
A particularly interesting feature is Make Real - an AI integration that lets you draw a wireframe on the whiteboard and then automatically generate working HTML/CSS based on the sketch. This demonstrates tldraw's potential as a tool not just for planning, but also for prototyping.
Use online
Head over to tldraw.com and start drawing - no registration, no login, right away.
Why tldraw?
Key advantages
- 100% free - Open-source, Apache 2.0 License
- No registration - Instant access to the whiteboard
- Infinite canvas - Unlimited drawing space
- Real-time collaboration - Multiple people working live together
- React embedding - Easy integration with applications
- Export - PNG, SVG, JSON, copy as image
- Multiplayer - Link sharing, cursor presence
- Customization - Extensible API for custom tools
- Make Real - AI generates code from wireframes
- Self-hosting - Ability to host on your own server
tldraw vs the competition
| Feature | tldraw | Excalidraw | Miro | Figma |
|---|---|---|---|---|
| Price | Free | Free | Freemium | Freemium |
| Open-source | ✅ Apache 2.0 | ✅ MIT | ❌ | ❌ |
| Style | Clean/Smooth | Hand-drawn | Polished | Professional |
| React library | ✅ Full | ✅ Basic | ❌ | ❌ |
| Multiplayer | ✅ | ✅ | ✅ | ✅ |
| Self-hosting | ✅ | ✅ | ❌ | ❌ |
| AI integration | ✅ Make Real | ❌ | Limited | ✅ AI features |
| No registration | ✅ | ✅ | ❌ | ❌ |
| Customization | High | Medium | Low | Low |
Features and tools
Drawing tools
┌─────────────────────────────────────────────────────────┐
│ TOOLBAR │
├─────────────────────────────────────────────────────────┤
│ 🔲 Select - Select and move elements │
│ ✏️ Draw - Freehand drawing │
│ ↗️ Arrow - Arrows with automatic connections │
│ ⬜ Rectangle - Rectangles and squares │
│ ⭕ Ellipse - Ellipses and circles │
│ △ Triangle - Triangles │
│ ⬡ Polygon - Polygons │
│ ⬢ Diamond - Diamonds │
│ ╱ Line - Straight lines │
│ A Text - Text │
│ 📝 Note - Sticky notes │
│ 🖼️ Image - Images │
│ 📦 Frame - Frames for grouping │
│ ✂️ Crop - Image cropping │
│ 🎨 Highlight - Highlighter │
│ 🔗 Embed - Embedding external content │
└─────────────────────────────────────────────────────────┘Element properties
┌─────────────────────────────────────────────────────────┐
│ STYLE OPTIONS │
├─────────────────────────────────────────────────────────┤
│ Color: 🔵 Blue 🟢 Green 🔴 Red 🟡 Yellow ⚫ Black │
│ Fill: None | Solid | Pattern | Semi │
│ Dash: Solid | Dashed | Dotted │
│ Size: S | M | L | XL │
│ Font: Sans | Serif | Mono | Draw │
│ Opacity: 0% ────────────────────────── 100% │
└─────────────────────────────────────────────────────────┘Keyboard shortcuts
# Navigation
Space + Drag # Pan the view
Scroll # Zoom
Ctrl/Cmd + 0 # Zoom to fit
Ctrl/Cmd + 1 # Zoom 100%
Ctrl/Cmd + 2 # Zoom to selection
# Tools
V # Select
D # Draw
A # Arrow
R # Rectangle
O # Ellipse
T # Text
N # Note
F # Frame
# Actions
Ctrl/Cmd + Z # Undo
Ctrl/Cmd + Y # Redo
Ctrl/Cmd + C # Copy
Ctrl/Cmd + V # Paste
Ctrl/Cmd + D # Duplicate
Delete # Delete
Ctrl/Cmd + G # Group
Ctrl/Cmd + Shift + G # Ungroup
# Export
Ctrl/Cmd + Shift + C # Copy as PNG
Ctrl/Cmd + Shift + E # ExportCollaboration
┌─────────────────────────────────────────────────────────┐
│ MULTIPLAYER FEATURES │
├─────────────────────────────────────────────────────────┤
│ 🔗 Share Link - Generate a collaboration link │
│ 👥 Cursors - See other people's cursors │
│ 💬 Comments - Add comments │
│ 👤 Presence - List of people on the board │
│ 🔒 Read-only - Read-only links │
│ 📱 Mobile - Works on phones │
└─────────────────────────────────────────────────────────┘
# Sharing
1. Click "Share" in the top right corner
2. Copy the link
3. Send it to collaborators
4. Real-time collaboration!Embedding in React
Basic installation
npm install tldraw
# or
pnpm add tldraw
# or
yarn add tldrawBasic component
// components/Whiteboard.tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export function Whiteboard() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw />
</div>
)
}With persistence
// components/PersistentWhiteboard.tsx
import { Tldraw, createTLStore, defaultShapeUtils, TLDocument } from 'tldraw'
import 'tldraw/tldraw.css'
import { useEffect, useState } from 'react'
const STORAGE_KEY = 'my-whiteboard'
export function PersistentWhiteboard() {
const [store] = useState(() => {
const store = createTLStore({ shapeUtils: defaultShapeUtils })
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const snapshot = JSON.parse(saved)
store.loadSnapshot(snapshot)
}
return store
})
useEffect(() => {
const unsubscribe = store.listen(() => {
const snapshot = store.getSnapshot()
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot))
})
return unsubscribe
}, [store])
return (
<div style={{ height: '100vh' }}>
<Tldraw store={store} />
</div>
)
}Custom tools
// components/CustomToolWhiteboard.tsx
import {
Tldraw,
TLUiOverrides,
TLUiActionsContextType,
DefaultToolbar,
useTools,
TldrawUiMenuItem
} from 'tldraw'
import 'tldraw/tldraw.css'
const stampTool = {
id: 'stamp',
icon: 'stamp-icon',
label: 'Stamp',
onSelect: (editor: any) => {
}
}
const uiOverrides: TLUiOverrides = {
tools: (editor, tools) => {
return {
...tools,
stamp: {
id: 'stamp',
icon: 'stamp',
label: 'Stamp',
kbd: 's',
onSelect: () => {
editor.setCurrentTool('stamp')
}
}
}
},
actions: (editor, actions) => {
return {
...actions,
'clear-all': {
id: 'clear-all',
label: 'Clear All',
kbd: 'shift+c',
onSelect: () => {
editor.deleteAll()
}
}
}
}
}
function CustomToolbar() {
const tools = useTools()
return (
<DefaultToolbar>
<TldrawUiMenuItem {...tools['select']} />
<TldrawUiMenuItem {...tools['draw']} />
<TldrawUiMenuItem {...tools['arrow']} />
<TldrawUiMenuItem {...tools['rectangle']} />
<TldrawUiMenuItem {...tools['text']} />
<TldrawUiMenuItem {...tools['stamp']} />
</DefaultToolbar>
)
}
export function CustomToolWhiteboard() {
return (
<div style={{ height: '100vh' }}>
<Tldraw
overrides={uiOverrides}
components={{
Toolbar: CustomToolbar
}}
/>
</div>
)
}Custom shapes
// shapes/StarShape.tsx
import {
BaseBoxShapeUtil,
Geometry2d,
HTMLContainer,
RecordProps,
T,
TLBaseShape,
Polygon2d,
Vec,
} from 'tldraw'
type IStarShape = TLBaseShape<
'star',
{
w: number
h: number
points: number
color: string
}
>
const starShapeProps: RecordProps<IStarShape> = {
w: T.number,
h: T.number,
points: T.number,
color: T.string,
}
function getStarPoints(cx: number, cy: number, spikes: number, outerRadius: number, innerRadius: number) {
const points: Vec[] = []
const step = Math.PI / spikes
for (let i = 0; i < 2 * spikes; i++) {
const radius = i % 2 === 0 ? outerRadius : innerRadius
const angle = i * step - Math.PI / 2
points.push(new Vec(cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)))
}
return points
}
export class StarShapeUtil extends BaseBoxShapeUtil<IStarShape> {
static override type = 'star' as const
static override props = starShapeProps
override getDefaultProps(): IStarShape['props'] {
return {
w: 100,
h: 100,
points: 5,
color: '#ffd700',
}
}
override getGeometry(shape: IStarShape): Geometry2d {
const points = getStarPoints(
shape.props.w / 2,
shape.props.h / 2,
shape.props.points,
Math.min(shape.props.w, shape.props.h) / 2,
Math.min(shape.props.w, shape.props.h) / 4
)
return new Polygon2d({ points, isFilled: true })
}
override component(shape: IStarShape) {
const points = getStarPoints(
shape.props.w / 2,
shape.props.h / 2,
shape.props.points,
Math.min(shape.props.w, shape.props.h) / 2,
Math.min(shape.props.w, shape.props.h) / 4
)
const pathData = points
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`)
.join(' ') + ' Z'
return (
<HTMLContainer>
<svg width={shape.props.w} height={shape.props.h}>
<path
d={pathData}
fill={shape.props.color}
stroke="black"
strokeWidth={2}
/>
</svg>
</HTMLContainer>
)
}
override indicator(shape: IStarShape) {
const points = getStarPoints(
shape.props.w / 2,
shape.props.h / 2,
shape.props.points,
Math.min(shape.props.w, shape.props.h) / 2,
Math.min(shape.props.w, shape.props.h) / 4
)
const pathData = points
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`)
.join(' ') + ' Z'
return <path d={pathData} />
}
}
import { Tldraw, defaultShapeUtils } from 'tldraw'
import { StarShapeUtil } from './shapes/StarShape'
const customShapeUtils = [...defaultShapeUtils, StarShapeUtil]
function App() {
return (
<Tldraw shapeUtils={customShapeUtils} />
)
}Multiplayer with Yjs
// components/MultiplayerWhiteboard.tsx
import { Tldraw, useSync } from 'tldraw'
import { useSyncDemo } from '@tldraw/sync'
import 'tldraw/tldraw.css'
export function MultiplayerWhiteboard({ roomId }: { roomId: string }) {
const store = useSyncDemo({
roomId,
userPreferences: {
name: 'User',
color: '#ff0000',
}
})
return (
<div style={{ height: '100vh' }}>
<Tldraw
store={store}
autoFocus
/>
</div>
)
}// Custom backend with Yjs
import { Tldraw, TLStore, TLStoreWithStatus } from 'tldraw'
import { useYjsStore } from './useYjsStore'
function useYjsStore(roomId: string): TLStoreWithStatus {
// Yjs synchronization implementation
// See tldraw documentation for details
}
export function CustomMultiplayerWhiteboard({ roomId }: { roomId: string }) {
const storeWithStatus = useYjsStore(roomId)
return (
<div style={{ height: '100vh' }}>
<Tldraw store={storeWithStatus} />
</div>
)
}Make Real - AI integration
How Make Real works
┌─────────────────────────────────────────────────────────┐
│ MAKE REAL WORKFLOW │
├─────────────────────────────────────────────────────────┤
│ 1. Draw a wireframe on the whiteboard │
│ ┌─────────────────────┐ │
│ │ [Logo] Nav Nav │ │
│ │ │ │
│ │ Hero Section │ │
│ │ [ Button ] │ │
│ │ │ │
│ └─────────────────────┘ │
│ │
│ 2. Select the elements │
│ │
│ 3. Click "Make Real" │
│ │
│ 4. AI generates HTML/CSS/JS │
│ ```html │
│ <nav class="flex justify-between p-4"> │
│ <div class="logo">Logo</div> │
│ <div class="nav-links">...</div> │
│ </nav> │
│ ... │
│ ``` │
│ │
│ 5. Preview in the browser! │
└─────────────────────────────────────────────────────────┘Implementing Make Real
// Custom Make Real implementation
import { Tldraw, Editor, TLShapeId } from 'tldraw'
import Anthropic from '@anthropic-ai/sdk'
const anthropic = new Anthropic()
async function makeReal(editor: Editor, selectedIds: TLShapeId[]) {
// 1. Export the selection as an image
const blob = await editor.exportShapes(selectedIds, {
type: 'png',
scale: 2,
})
// 2. Convert to base64
const base64 = await blobToBase64(blob)
// 3. Send to Claude with a prompt
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-5-20241022',
max_tokens: 4096,
messages: [{
role: 'user',
content: [
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: base64
}
},
{
type: 'text',
text: `Transform this wireframe into HTML code with Tailwind CSS.
Return only the HTML code, no explanations.
The code should be responsive and semantic.`
}
]
}]
})
// 4. Extract HTML from the response
const html = extractCode(response.content[0].text)
// 5. Display the preview
openPreview(html)
}
// Add a button to tldraw
const uiOverrides = {
actions: (editor: Editor, actions: any) => ({
...actions,
'make-real': {
id: 'make-real',
label: 'Make Real',
kbd: 'shift+m',
onSelect: () => {
const selectedIds = editor.getSelectedShapeIds()
if (selectedIds.length > 0) {
makeReal(editor, selectedIds)
}
}
}
})
}Self-hosting
Docker
# docker-compose.yml
version: '3.8'
services:
tldraw:
image: tldraw/tldraw:latest
ports:
- "3000:3000"
environment:
- NODE_ENV=production
volumes:
- tldraw_data:/app/data
volumes:
tldraw_data:# Launch
docker-compose up -d
# Access
# http://localhost:3000Custom instance with Next.js
# Create a Next.js project with tldraw
npx create-next-app@latest my-tldraw --typescript
cd my-tldraw
npm install tldraw// app/page.tsx
'use client'
import dynamic from 'next/dynamic'
const Tldraw = dynamic(
async () => (await import('tldraw')).Tldraw,
{ ssr: false }
)
import 'tldraw/tldraw.css'
export default function Home() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw />
</div>
)
}Backend with Cloudflare Workers
// workers/tldraw-sync.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
const app = new Hono()
app.use('*', cors())
const documents = new Map<string, any>()
app.get('/room/:id', async (c) => {
const id = c.req.param('id')
const doc = documents.get(id) || { shapes: [], bindings: [] }
return c.json(doc)
})
app.put('/room/:id', async (c) => {
const id = c.req.param('id')
const data = await c.req.json()
documents.set(id, data)
return c.json({ success: true })
})
export default appExport and import
Programmatic export
import { Tldraw, Editor } from 'tldraw'
function ExportButtons({ editor }: { editor: Editor }) {
const exportPng = async () => {
const shapeIds = editor.getCurrentPageShapeIds()
const blob = await editor.exportShapes([...shapeIds], {
type: 'png',
scale: 2,
background: true
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'whiteboard.png'
a.click()
}
const exportSvg = async () => {
const shapeIds = editor.getCurrentPageShapeIds()
const svg = await editor.exportShapesSvg([...shapeIds], {
background: true
})
const blob = new Blob([svg.innerHTML], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'whiteboard.svg'
a.click()
}
const exportJson = () => {
const snapshot = editor.store.getSnapshot()
const json = JSON.stringify(snapshot, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'whiteboard.json'
a.click()
}
return (
<div className="export-buttons">
<button onClick={exportPng}>Export PNG</button>
<button onClick={exportSvg}>Export SVG</button>
<button onClick={exportJson}>Export JSON</button>
</div>
)
}Import from JSON
function ImportButton({ editor }: { editor: Editor }) {
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const text = await file.text()
const snapshot = JSON.parse(text)
editor.store.loadSnapshot(snapshot)
}
return (
<input
type="file"
accept=".json"
onChange={handleImport}
/>
)
}Use cases
Technical documentation
// Embed in documentation (e.g. Docusaurus)
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export function DiagramEmbed({ snapshotUrl }: { snapshotUrl: string }) {
const [snapshot, setSnapshot] = useState(null)
useEffect(() => {
fetch(snapshotUrl)
.then(r => r.json())
.then(setSnapshot)
}, [snapshotUrl])
if (!snapshot) return <div>Loading...</div>
return (
<div style={{ height: 400, border: '1px solid #ccc' }}>
<Tldraw
snapshot={snapshot}
hideUi
/>
</div>
)
}Team brainstorming
// Brainstorming room
export function BrainstormRoom({ teamId }: { teamId: string }) {
const store = useSyncDemo({
roomId: `brainstorm-${teamId}`,
userPreferences: {
name: currentUser.name,
color: currentUser.color
}
})
return (
<div className="h-screen">
<header className="p-4 bg-gray-100 flex justify-between">
<h1>Team Brainstorm</h1>
<ParticipantsList />
</header>
<div className="h-[calc(100vh-60px)]">
<Tldraw store={store} />
</div>
</div>
)
}Online education
// Teacher whiteboard with recording capability
export function TeacherWhiteboard() {
const editorRef = useRef<Editor | null>(null)
const [isRecording, setIsRecording] = useState(false)
const [history, setHistory] = useState<any[]>([])
const startRecording = () => {
setIsRecording(true)
editorRef.current?.store.listen((update) => {
setHistory(h => [...h, { ...update, timestamp: Date.now() }])
})
}
const playback = () => {
history.forEach((update, i) => {
setTimeout(() => {
editorRef.current?.store.applyDiff(update)
}, update.timestamp - history[0].timestamp)
})
}
return (
<div>
<div className="controls">
<button onClick={startRecording}>
{isRecording ? '⏹ Stop' : '⏺ Record'}
</button>
<button onClick={playback}>▶ Playback</button>
</div>
<Tldraw
onMount={(editor) => { editorRef.current = editor }}
/>
</div>
)
}Pricing
| Plan | Price | Includes |
|---|---|---|
| Open Source | $0 | Full library, self-hosting, unlimited |
| tldraw.com | $0 | Free online whiteboard, multiplayer |
| Enterprise | Contact | Dedicated support, SLA, custom features |
tldraw is 100% free under the Apache 2.0 license.
FAQ - Frequently asked questions
Can I use tldraw commercially?
Yes, the Apache 2.0 license allows commercial use without restrictions.
How do I save the whiteboard state?
Use editor.store.getSnapshot() to export and editor.store.loadSnapshot() to import. You can save to localStorage, a database, or send it to a server.
Does tldraw work offline?
Yes, the core functionality works offline. For multiplayer you need a connection.
How do I add custom shapes?
Create a class extending BaseBoxShapeUtil or BaseShapeUtil, define type, props, component, and indicator, then pass it to shapeUtils.
Does tldraw support touch/stylus?
Yes, tldraw has full support for touch, stylus, and pressure sensitivity.
How do I integrate with a backend?
Use the official @tldraw/sync or implement your own synchronization with Yjs, WebSocket, or REST API.
Can I hide the UI?
Yes, use the hideUi prop or the components prop to selectively hide elements.
Summary
tldraw is a powerful, free visualization tool:
- 100% open-source - Apache 2.0, no hidden costs
- Infinite canvas - Unlimited drawing space
- React library - Easy integration with applications
- Real-time multiplayer - Collaboration in real time
- Make Real - AI generates code from wireframes
- Customizable - Custom tools, shapes, UI
- Self-hostable - Full control over your data
tldraw is the perfect choice for teams looking for a free, extensible whiteboard for diagrams, wireframes, and collaboration.