Implementing Whiteboard (Collaborative Board) on a Website
A collaborative whiteboard is an intersection of several technical areas: high-frequency canvas rendering, CRDT state synchronization, cursor presence, and drawing tool UX. None of these parts can be simplified without compromising user experience.
Choosing the Rendering Stack
| Option | Suitable For |
|---|---|
| tldraw | Open-source whiteboard SDK, React, integrates in a day |
| Excalidraw | Open-source, good UX, but difficult to customize |
| Konva.js | Full canvas control, React-friendly |
| Fabric.js | Rich API, but aging |
| SVG + vanilla | For simple diagrams without transformations |
For a production product with custom requirements — tldraw as a base or Konva.js from scratch. Excalidraw is forked, but fork maintenance costs are high.
Architecture: Board State
A board is a set of shape objects. Each shape has a type, geometry, and styles:
type ShapeType = 'rect' | 'ellipse' | 'line' | 'arrow' | 'text' | 'freehand' | 'image';
interface BaseShape {
id: string;
type: ShapeType;
x: number;
y: number;
rotation: number;
opacity: number;
locked: boolean;
createdBy: string;
updatedAt: number;
}
interface RectShape extends BaseShape {
type: 'rect';
width: number;
height: number;
fill: string;
stroke: string;
strokeWidth: number;
cornerRadius: number;
}
interface FreehandShape extends BaseShape {
type: 'freehand';
points: [number, number][]; // absolute coordinates
stroke: string;
strokeWidth: number;
pressure: number[]; // for pressure-sensitive drawing
}
interface TextShape extends BaseShape {
type: 'text';
content: string;
fontSize: number;
fontFamily: string;
color: string;
width: number; // for text wrapping
}
type Shape = RectShape | FreehandShape | TextShape; // | ... other types
CRDT for Shape Synchronization
Y.Map is ideal — each shape is stored by its id:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const ydoc = new Y.Doc();
const yshapes = ydoc.getMap<Y.Map<unknown>>('shapes');
const provider = new WebsocketProvider(
process.env.WS_URL!,
`board-${boardId}`,
ydoc
);
// Adding a shape
function addShape(shape: Shape) {
const yshape = new Y.Map<unknown>(Object.entries(shape));
yshapes.set(shape.id, yshape);
}
// Updating (e.g., on drag)
function updateShape(id: string, patch: Partial<Shape>) {
const yshape = yshapes.get(id);
if (!yshape) return;
ydoc.transact(() => {
Object.entries(patch).forEach(([key, value]) => {
yshape.set(key, value);
});
});
}
// Deletion
function deleteShape(id: string) {
yshapes.delete(id);
}
// Reactivity
yshapes.observeDeep((events) => {
events.forEach((event) => {
// redraw changed shapes
rerenderCanvas(yshapes);
});
});
Freehand Drawing: Point Optimization
Freehand drawing accumulates hundreds of points. Sending all is expensive. Use the Ramer-Douglas-Peucker algorithm for curve simplification:
function rdp(points: [number, number][], epsilon: number): [number, number][] {
if (points.length < 3) return points;
let maxDist = 0;
let maxIdx = 0;
const end = points.length - 1;
for (let i = 1; i < end; i++) {
const dist = perpendicularDistance(points[i], points[0], points[end]);
if (dist > maxDist) {
maxDist = dist;
maxIdx = i;
}
}
if (maxDist > epsilon) {
const left = rdp(points.slice(0, maxIdx + 1), epsilon);
const right = rdp(points.slice(maxIdx), epsilon);
return [...left.slice(0, -1), ...right];
}
return [points[0], points[end]];
}
// During drawing — update each point locally
// On completion (pointerup) — simplify and synchronize
function finishFreehand(shapeId: string, rawPoints: [number, number][]) {
const simplified = rdp(rawPoints, 2.0); // epsilon in pixels
updateShape(shapeId, { points: simplified });
}
For smooth curves from points — use perfect-freehand library:
import getStroke from 'perfect-freehand';
function getFreehandPath(points: [number, number][], options = {}) {
const stroke = getStroke(points, {
size: 8,
thinning: 0.5,
smoothing: 0.5,
streamline: 0.5,
...options,
});
// stroke -> SVG path data
return getSvgPathFromStroke(stroke);
}
Viewport: Pan and Zoom
The board is infinite — a viewport transform is needed. All coordinates are stored in world space, viewport describes the current view:
interface Viewport {
x: number; // offset
y: number;
zoom: number; // 0.1 – 4.0
}
// World coordinates → screen
function worldToScreen(wx: number, wy: number, vp: Viewport) {
return {
x: wx * vp.zoom + vp.x,
y: wy * vp.zoom + vp.y,
};
}
// Screen → world (for pointer events)
function screenToWorld(sx: number, sy: number, vp: Viewport) {
return {
x: (sx - vp.x) / vp.zoom,
y: (sy - vp.y) / vp.zoom,
};
}
// Zoom at point (pinch or wheel)
function zoomAt(vp: Viewport, screenX: number, screenY: number, delta: number): Viewport {
const factor = delta > 0 ? 1.1 : 0.9;
const newZoom = Math.max(0.1, Math.min(4.0, vp.zoom * factor));
const zoomRatio = newZoom / vp.zoom;
return {
x: screenX - (screenX - vp.x) * zoomRatio,
y: screenY - (screenY - vp.y) * zoomRatio,
zoom: newZoom,
};
}
Canvas vs SVG: Load-Based Choice
With fewer than 500 shapes — SVG works fine and simplifies hit-testing. With more than 1000 shapes and active drawing — Canvas (2D or WebGL via Pixi.js).
Hybrid approach: shapes render in Canvas, UI elements (toolbar, selection handles, labels) — in HTML on top. Canvas for drawing, HTML for interactivity.
// React board component
const Whiteboard: React.FC = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext('2d')!;
// ResizeObserver for HiDPI
const ro = new ResizeObserver(() => {
canvas.width = canvas.offsetWidth * devicePixelRatio;
canvas.height = canvas.offsetHeight * devicePixelRatio;
ctx.scale(devicePixelRatio, devicePixelRatio);
render(ctx);
});
ro.observe(canvas);
return () => ro.disconnect();
}, []);
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />
<Toolbar />
<SelectionOverlay />
<CursorLayer /> {/* presence */}
</div>
);
};
History: Undo/Redo via Yjs
Yjs provides UndoManager:
const undoManager = new Y.UndoManager(yshapes, {
trackedOrigins: new Set([ydoc.clientID]),
captureTimeout: 500, // group operations in 500ms
});
// Operations must be marked with origin
ydoc.transact(() => {
yshapes.set(shape.id, yshape);
}, ydoc.clientID); // <- origin = clientID, added to undo stack
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'z') undoManager.undo();
if (e.ctrlKey && e.key === 'y') undoManager.redo();
});
Board Export
async function exportToPNG(boardId: string): Promise<Blob> {
const canvas = document.getElementById('whiteboard-canvas') as HTMLCanvasElement;
// Calculate bounding box of all shapes
const shapes = Array.from(yshapes.values()).map(s => shapeFromYMap(s));
const bbox = getBoundingBox(shapes);
// Create offscreen canvas of needed size
const offscreen = new OffscreenCanvas(bbox.width + 80, bbox.height + 80);
const ctx = offscreen.getContext('2d')!;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, offscreen.width, offscreen.height);
// Render with offset
renderShapes(ctx, shapes, { x: -bbox.x + 40, y: -bbox.y + 40, zoom: 1 });
return await offscreen.convertToBlob({ type: 'image/png' });
}
Timeline
tldraw integration with custom Yjs synchronization: 5–7 days. Whiteboard from scratch on Konva.js with freehand, shapes, viewport, undo, presence and export: 3–4 weeks. Adding video/audio via WebRTC alongside the board: another week.







