Whiteboards combine canvas drawing with real-time collaboration. Here is how to build one.
Drawing Types
export type Tool = "pen" | "line" | "rectangle" | "ellipse" | "eraser" | "text";
export interface Point {
x: number;
y: number;
}
export interface DrawElement {
id: string;
tool: Tool;
points: Point[];
color: string;
strokeWidth: number;
text?: string;
}
Canvas Hook
"use client";
import { useRef, useState, useCallback, useEffect } from "react";
import type { Tool, Point, DrawElement } from "./types";
interface UseCanvasOptions {
width: number;
height: number;
onElementAdded?: (element: DrawElement) => void;
}
export function useCanvas({ width, height, onElementAdded }: UseCanvasOptions) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [elements, setElements] = useState<DrawElement[]>([]);
const [undoStack, setUndoStack] = useState<DrawElement[][]>([]);
const [redoStack, setRedoStack] = useState<DrawElement[][]>([]);
const [tool, setTool] = useState<Tool>("pen");
const [color, setColor] = useState("#000000");
const [strokeWidth, setStrokeWidth] = useState(2);
const [drawing, setDrawing] = useState(false);
const currentElement = useRef<DrawElement | null>(null);
// Redraw all elements
const redraw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d")!;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
for (const element of elements) {
drawElement(ctx, element);
}
if (currentElement.current) {
drawElement(ctx, currentElement.current);
}
}, [elements, width, height]);
useEffect(() => {
redraw();
}, [redraw]);
const startDrawing = useCallback(
(point: Point) => {
setDrawing(true);
currentElement.current = {
id: crypto.randomUUID(),
tool,
points: [point],
color: tool === "eraser" ? "#ffffff" : color,
strokeWidth: tool === "eraser" ? strokeWidth * 5 : strokeWidth,
};
},
[tool, color, strokeWidth],
);
const continueDrawing = useCallback(
(point: Point) => {
if (!drawing || !currentElement.current) return;
currentElement.current.points.push(point);
redraw();
},
[drawing, redraw],
);
const stopDrawing = useCallback(() => {
if (!drawing || !currentElement.current) return;
setDrawing(false);
const element = currentElement.current;
currentElement.current = null;
setUndoStack((prev) => [...prev, elements]);
setRedoStack([]);
setElements((prev) => [...prev, element]);
onElementAdded?.(element);
}, [drawing, elements, onElementAdded]);
const undo = useCallback(() => {
if (undoStack.length === 0) return;
const previous = undoStack[undoStack.length - 1];
setRedoStack((prev) => [...prev, elements]);
setElements(previous);
setUndoStack((prev) => prev.slice(0, -1));
}, [elements, undoStack]);
const redo = useCallback(() => {
if (redoStack.length === 0) return;
const next = redoStack[redoStack.length - 1];
setUndoStack((prev) => [...prev, elements]);
setElements(next);
setRedoStack((prev) => prev.slice(0, -1));
}, [elements, redoStack]);
const clear = useCallback(() => {
setUndoStack((prev) => [...prev, elements]);
setRedoStack([]);
setElements([]);
}, [elements]);
const exportPng = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const link = document.createElement("a");
link.download = "whiteboard.png";
link.href = canvas.toDataURL("image/png");
link.click();
}, []);
const addRemoteElement = useCallback((element: DrawElement) => {
setElements((prev) => [...prev, element]);
}, []);
return {
canvasRef,
tool,
setTool,
color,
setColor,
strokeWidth,
setStrokeWidth,
startDrawing,
continueDrawing,
stopDrawing,
undo,
redo,
clear,
exportPng,
addRemoteElement,
canUndo: undoStack.length > 0,
canRedo: redoStack.length > 0,
};
}
function drawElement(ctx: CanvasRenderingContext2D, element: DrawElement) {
ctx.strokeStyle = element.color;
ctx.lineWidth = element.strokeWidth;
ctx.lineCap = "round";
ctx.lineJoin = "round";
const { points, tool } = element;
if (points.length === 0) return;
switch (tool) {
case "pen":
case "eraser": {
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
// Smooth curve through points
const mid = {
x: (points[i - 1].x + points[i].x) / 2,
y: (points[i - 1].y + points[i].y) / 2,
};
ctx.quadraticCurveTo(points[i - 1].x, points[i - 1].y, mid.x, mid.y);
}
ctx.stroke();
break;
}
case "line": {
if (points.length < 2) return;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y);
ctx.stroke();
break;
}
case "rectangle": {
if (points.length < 2) return;
const start = points[0];
const end = points[points.length - 1];
ctx.strokeRect(start.x, start.y, end.x - start.x, end.y - start.y);
break;
}
case "ellipse": {
if (points.length < 2) return;
const s = points[0];
const e = points[points.length - 1];
const cx = (s.x + e.x) / 2;
const cy = (s.y + e.y) / 2;
const rx = Math.abs(e.x - s.x) / 2;
const ry = Math.abs(e.y - s.y) / 2;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
ctx.stroke();
break;
}
}
}
Whiteboard Component
"use client";
import { useCanvas } from "./useCanvas";
import type { Point, Tool } from "./types";
const tools: { id: Tool; label: string; icon: string }[] = [
{ id: "pen", label: "Pen", icon: "βοΈ" },
{ id: "line", label: "Line", icon: "π" },
{ id: "rectangle", label: "Rectangle", icon: "β¬" },
{ id: "ellipse", label: "Ellipse", icon: "β" },
{ id: "eraser", label: "Eraser", icon: "π§Ή" },
];
const colors = ["#000000", "#ef4444", "#3b82f6", "#22c55e", "#eab308", "#a855f7"];
export function Whiteboard() {
const {
canvasRef,
tool,
setTool,
color,
setColor,
strokeWidth,
setStrokeWidth,
startDrawing,
continueDrawing,
stopDrawing,
undo,
redo,
clear,
exportPng,
canUndo,
canRedo,
} = useCanvas({
width: 1200,
height: 800,
onElementAdded: (element) => {
// Broadcast to other users via WebSocket
},
});
function getPoint(e: React.MouseEvent<HTMLCanvasElement>): Point {
const rect = e.currentTarget.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
}
return (
<div className="flex flex-col gap-2 border rounded-lg overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-2 p-2 bg-muted/50 border-b flex-wrap">
{/* Tools */}
<div className="flex gap-1">
{tools.map((t) => (
<button
key={t.id}
onClick={() => setTool(t.id)}
className={`px-2 py-1 text-sm rounded ${
tool === t.id ? "bg-primary text-primary-foreground" : "hover:bg-muted"
}`}
title={t.label}
>
{t.label}
</button>
))}
</div>
<div className="w-px h-6 bg-border" />
{/* Colors */}
<div className="flex gap-1">
{colors.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
className={`w-6 h-6 rounded-full ${
color === c ? "ring-2 ring-primary ring-offset-1" : ""
}`}
style={{ backgroundColor: c }}
aria-label={`Color ${c}`}
/>
))}
</div>
<div className="w-px h-6 bg-border" />
{/* Stroke width */}
<label className="flex items-center gap-1 text-xs">
Size:
<input
type="range"
min="1"
max="20"
value={strokeWidth}
onChange={(e) => setStrokeWidth(Number(e.target.value))}
className="w-20"
/>
<span className="w-4">{strokeWidth}</span>
</label>
<div className="flex-1" />
{/* Actions */}
<button
onClick={undo}
disabled={!canUndo}
className="px-2 py-1 text-xs border rounded disabled:opacity-50"
>
Undo
</button>
<button
onClick={redo}
disabled={!canRedo}
className="px-2 py-1 text-xs border rounded disabled:opacity-50"
>
Redo
</button>
<button onClick={clear} className="px-2 py-1 text-xs border rounded text-red-500">
Clear
</button>
<button onClick={exportPng} className="px-2 py-1 text-xs bg-primary text-primary-foreground rounded">
Export PNG
</button>
</div>
{/* Canvas */}
<canvas
ref={canvasRef}
width={1200}
height={800}
className="cursor-crosshair"
onMouseDown={(e) => startDrawing(getPoint(e))}
onMouseMove={(e) => continueDrawing(getPoint(e))}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
/>
</div>
);
}
Need Collaboration Tools?
We build collaborative applications for teams. Contact us to discuss your project.