Skip to main content
Back to Blog
Tutorials
4 min read
January 27, 2025

How to Build a Real-Time Collaborative Whiteboard in React

Create a collaborative whiteboard with HTML Canvas drawing, shape tools, real-time cursor sharing, undo-redo, and export.

Ryel Banfield

Founder & Lead Developer

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.

whiteboardcanvascollaborationreal-timeReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles