Build an Offline-First Collaborative Canvas

Build a real-time collaborative drawing app using CRDTs that works offline.

Introduction

30 Second Summary

Think about how Google Docs lets multiple people edit the same document at once, even when someone's internet drops out. Behind the scenes, a clever system ensures everyone's changes merge together without conflicts.

In this project, you will build a collaborative drawing canvas where multiple users draw shapes that sync in real-time without conflicts. The canvas works fully offline and needs zero server infrastructure.

What You'll Build

A React + Vite app with a Konva-based drawing canvas, backed by Yjs CRDTs for conflict-free collaboration over peer-to-peer WebRTC.

By the end of this project, you'll have:

  • A full-screen drawing canvas with rectangle, circle, and freehand tools powered by React and Konva.
  • Real-time peer-to-peer sync using Yjs CRDTs and WebRTC so multiple users can draw simultaneously with zero server infrastructure.
  • Offline persistence via IndexedDB so your canvas survives page reloads and network disconnections, plus live cursor awareness showing where other users are pointing.
  • Secret Mission: Add per-user undo/redo that only reverses your own changes, not your collaborators'.

Are there any prerequisites?

You need basic JavaScript knowledge (variables, functions, arrays). No prior React or TypeScript experience is required. This project introduces both as you build.

Set Up Your Project

Before you can build a collaborative canvas, you need a local development environment with the right tools wired together.

In this step you will scaffold a React + TypeScript project using Vite, then install the libraries that power real-time collaboration and canvas drawing.

In this step, get ready to:
  • Create a React + TypeScript project with Vite.
  • Install the CRDT, networking, and canvas drawing libraries.
  • Start the development server and confirm everything works.
Create a React + TypeScript project with Vite

Vite is a modern build tool that scaffolds a ready-to-go project in seconds. You will use it to generate a React + TypeScript starter with a dev server that hot-reloads on every save.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (the backtick key, works on both macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold your project by running this command:
npm create vite@latest collab-canvas -- --template react-ts

What will I see in the terminal?

npm may ask you to confirm installing the create-vite package. Press Enter or type y to continue.

If you see a prompt asking whether to install dependencies and start the server automatically, select No. You will do that manually in the next steps.

  • Move into the new project folder by running:
cd collab-canvas
  • Install the base project dependencies by running:
npm install
  • Confirm the project was created by running:
ls

You should see files like index.html, package.json, src/, and a node_modules/ folder in the output.

Seeing 'command not found' or no files?

Make sure you ran cd collab-canvas after the scaffold command completed. If npm itself is not found, verify Node.js is installed by running node --version in a fresh terminal window.

Help me troubleshoot my Vite project setup.

Install the collaboration and drawing libraries

Your canvas needs five additional libraries that handle real-time sync, offline storage, and shape rendering.

  • Install all five libraries by running this command:
npm install yjs y-webrtc y-indexeddb konva react-konva

What do these libraries do?

  • yjs provides CRDTs (Conflict-free Replicated Data Types) that let multiple users edit the same data without conflicts.
  • y-webrtc syncs changes peer-to-peer over WebRTC with no server needed.
  • y-indexeddb persists your canvas data to the browser's IndexedDB so drawings survive page reloads and offline sessions.
  • konva and react-konva give you a Konva-based canvas where every shape is a JavaScript object you can draw, move, and style.

You should see a success message with the number of packages added. No errors or warnings about peer dependencies are expected.

Install failed or showing errors?

Make sure you are inside the collab-canvas folder (run pwd to check). If you see network errors, check your internet connection and try the command again.

Help me fix my npm install errors.

Start the development server

Vite includes a built-in dev server that watches your files and refreshes the browser instantly when you make changes.

  • Start the dev server by running:
npm run dev
  • Open your browser and navigate to http://localhost:5173.

You should see the default Vite + React page with a Vite logo, a React logo, and a counter button that increments when clicked.

Page not loading?

Check your terminal for error messages. Make sure the server is still running (you should see a local URL printed in the output). If port 5173 is in use, Vite will pick a different port and display it in the terminal.

Help me get my Vite dev server running.

Your development environment is ready. Next up, you will replace this default page with a full-screen drawing canvas.

Create a Basic Drawing Canvas

Your project is set up with all the libraries you need. Now it is time to create the drawing surface where shapes will live.

Every drawing app needs a canvas. In this step, you will replace the default Vite template with a full-screen Konva Stage and add a toolbar so users can pick which shape to draw.

In this step, get ready to:
  • Replace the default HTML and App component with a blank full-screen canvas.
  • Add a toolbar with Rectangle, Circle, and Freehand buttons.
  • Track the selected tool with React state so the active button highlights.
Set up a full-screen canvas

The default Vite template adds margins and padding to the page. A drawing canvas needs to fill the entire browser window with zero gaps.

  • In VS Code, open index.html from the root of your collab-canvas folder.
  • Delete everything in the file and replace it with this code:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Collaborative Canvas</title>
    <style>
      /* Remove all default spacing from elements */
      * { margin: 0; padding: 0; box-sizing: border-box; }
      /* Prevent scrollbars and set a clean font */
      body { overflow: hidden; font-family: system-ui, sans-serif; }
    </style>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

What does this code do?

The * selector resets margins and padding on every element so the canvas can sit flush against the edges of the viewport.

Setting overflow: hidden on the body prevents scrollbars from appearing when the canvas fills the screen.

  • Save index.html.

Now you need to replace the default App component and create a Canvas component that renders a Konva Stage.

  • Open src/App.tsx in VS Code.
  • Delete everything in the file and replace it with this code:
import { Canvas } from './Canvas'

function App() {
  return <Canvas />
}

export default App
  • Create a new file called Canvas.tsx inside the src folder.
  • Add the following code to src/Canvas.tsx:
import { Stage, Layer } from 'react-konva'

export function Canvas() {
  return (
    <div style={{ width: '100vw', height: '100vh', overflow: 'hidden' }}>
      <Stage
        width={window.innerWidth}
        height={window.innerHeight}
      >
        <Layer />
      </Stage>
    </div>
  )
}

What does this code do?

The Stage component is Konva's top-level container. It creates an HTML5 canvas element sized to the full browser window.

The Layer component is where shapes will be drawn. Every Konva shape must live inside a Layer.

  • Save both src/App.tsx and src/Canvas.tsx.
  • Switch to your browser at http://localhost:5173. You should see a completely blank white page with no text, logos, or counter button.

Still seeing the Vite template?

Make sure you saved both files. The dev server hot-reloads on save, so the page should update automatically.

If you see an error mentioning a missing module, double-check that src/Canvas.tsx exists and that the import in App.tsx uses './Canvas' (capital C, no file extension).

help me debug why my canvas page is still showing the Vite template

Add a toolbar with drawing tools

Your canvas is blank and ready. Now you need a way for users to pick which shape they want to draw before clicking on the canvas.

  • Open src/Canvas.tsx and replace its entire contents with this code:
import { useState } from 'react'
import { Stage, Layer } from 'react-konva'

// Define the three tool types as a union
type Tool = 'rect' | 'circle' | 'line'

export function Canvas() {
  // Track which drawing tool is currently selected
  const [tool, setTool] = useState<Tool>('rect')

  return (
    <div style={{ width: '100vw', height: '100vh', overflow: 'hidden' }}>
      {/* Floating toolbar positioned over the canvas */}
      <div
        style={{
          position: 'absolute',
          top: 10,
          left: 10,
          zIndex: 10,
          display: 'flex',
          gap: '8px',
          alignItems: 'center',
          background: '#fff',
          padding: '8px 12px',
          borderRadius: '8px',
          boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
        }}
      >
        <button
          onClick={() => setTool('rect')}
          style={{
            padding: '6px 12px',
            background: tool === 'rect' ? '#3b82f6' : '#e5e7eb',
            color: tool === 'rect' ? '#fff' : '#1f2937',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          Rectangle
        </button>
        <button
          onClick={() => setTool('circle')}
          style={{
            padding: '6px 12px',
            background: tool === 'circle' ? '#3b82f6' : '#e5e7eb',
            color: tool === 'circle' ? '#fff' : '#1f2937',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          Circle
        </button>
        <button
          onClick={() => setTool('line')}
          style={{
            padding: '6px 12px',
            background: tool === 'line' ? '#3b82f6' : '#e5e7eb',
            color: tool === 'line' ? '#fff' : '#1f2937',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          Freehand
        </button>
      </div>

      <Stage
        width={window.innerWidth}
        height={window.innerHeight}
      >
        <Layer />
      </Stage>
    </div>
  )
}

What does this code do?

The Tool type restricts the selected tool to one of three values: rect, circle, or line. This prevents bugs from invalid tool names.

The useState hook stores which tool is active. When a button is clicked, setTool updates the state, which triggers a re-render.

Each button's background color is set conditionally. If the button's tool matches the current state, it turns blue. Otherwise it stays grey. This gives the user instant visual feedback about which tool is selected.

  • Save src/Canvas.tsx.
  • Switch to your browser. You should see a blank white canvas with a floating toolbar in the top-left corner containing three buttons: Rectangle, Circle, and Freehand.
  • Click each button. The active button turns blue while the others stay grey.
  • Nothing draws on the canvas yet. That comes in the next step.

Toolbar not appearing?

Check that your Canvas.tsx import includes useState from 'react'. Without it, the component cannot track tool selection and will throw an error.

If the toolbar appears but buttons do not change color, make sure the conditional style uses tool === 'rect' (triple equals), not a single equals sign.

help me fix my toolbar buttons not highlighting

✔️ Awesome, I've got everything!

Great. Double check that you have saved all three files before moving on.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Collaborative Canvas</title>
    <style>
      * { margin: 0; padding: 0; box-sizing: border-box; }
      body { overflow: hidden; font-family: system-ui, sans-serif; }
    </style>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
import { Canvas } from './Canvas'

function App() {
  return <Canvas />
}

export default App
import { useState } from 'react'
import { Stage, Layer } from 'react-konva'

type Tool = 'rect' | 'circle' | 'line'

export function Canvas() {
  const [tool, setTool] = useState<Tool>('rect')

  return (
    <div style={{ width: '100vw', height: '100vh', overflow: 'hidden' }}>
      <div
        style={{
          position: 'absolute',
          top: 10,
          left: 10,
          zIndex: 10,
          display: 'flex',
          gap: '8px',
          alignItems: 'center',
          background: '#fff',
          padding: '8px 12px',
          borderRadius: '8px',
          boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
        }}
      >
        <button
          onClick={() => setTool('rect')}
          style={{
            padding: '6px 12px',
            background: tool === 'rect' ? '#3b82f6' : '#e5e7eb',
            color: tool === 'rect' ? '#fff' : '#1f2937',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          Rectangle
        </button>
        <button
          onClick={() => setTool('circle')}
          style={{
            padding: '6px 12px',
            background: tool === 'circle' ? '#3b82f6' : '#e5e7eb',
            color: tool === 'circle' ? '#fff' : '#1f2937',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          Circle
        </button>
        <button
          onClick={() => setTool('line')}
          style={{
            padding: '6px 12px',
            background: tool === 'line' ? '#3b82f6' : '#e5e7eb',
            color: tool === 'line' ? '#fff' : '#1f2937',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          Freehand
        </button>
      </div>

      <Stage
        width={window.innerWidth}
        height={window.innerHeight}
      >
        <Layer />
      </Stage>
    </div>
  )
}

Your canvas is ready and your toolbar lets users pick a tool. Next up, you will wire pointer events to the Stage so clicking and dragging actually draws shapes.

Make Shapes Appear on the Canvas

Your canvas is blank and ready. The toolbar lets you pick a shape tool, but clicking and dragging does nothing yet.

In this step, you'll wire up pointer events so the canvas responds to clicks and drags. Each click-and-drag gesture creates a shape object in local React state, and Konva renders it on the canvas instantly.

In this step, get ready to:
  • Add pointer event handlers that track mouse position and create shapes.
  • Render finished shapes as draggable Konva components.
  • Draw rectangles, circles, and freehand lines with random colors.
Add pointer event handlers for drawing

Drawing on a canvas works like a three-phase gesture. Pointer down starts the shape, pointer move resizes it as you drag, and pointer up finalizes it.

  • In VS Code, open src/Canvas.tsx and replace its entire contents with the following code:
import { useState, useRef, useCallback } from 'react'
import { Stage, Layer, Rect, Circle, Line } from 'react-konva'
import { KonvaEventObject } from 'konva/lib/Node'

// Shape data structure for every object on the canvas
interface ShapeData {
  id: string
  type: 'rect' | 'circle' | 'line'
  x: number
  y: number
  width?: number
  height?: number
  radius?: number
  points?: number[]
  fill: string
  stroke: string
  strokeWidth: number
}

type Tool = 'rect' | 'circle' | 'line'

// Five colors shapes can randomly be assigned
const COLORS = ['#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6']

// Generate a short random ID for each shape
function generateId(): string {
  return Math.random().toString(36).substring(2, 10)
}

// Pick a random color from the palette
function getRandomColor(): string {
  return COLORS[Math.floor(Math.random() * COLORS.length)]
}

export function Canvas() {
  const [shapes, setShapes] = useState<ShapeData[]>([])
  const [tool, setTool] = useState<Tool>('rect')
  const [isDrawing, setIsDrawing] = useState(false)
  const [currentShape, setCurrentShape] = useState<ShapeData | null>(null)
  const stageRef = useRef<any>(null)

  // Update the in-progress shape as the pointer moves
  const handlePointerMove = useCallback(
    (e: KonvaEventObject<PointerEvent>) => {
      const stage = e.target.getStage()
      if (!stage) return
      const pos = stage.getPointerPosition()
      if (!pos) return

      if (!isDrawing || !currentShape) return

      if (currentShape.type === 'line') {
        setCurrentShape({
          ...currentShape,
          points: [...(currentShape.points || []), pos.x, pos.y],
        })
      } else if (currentShape.type === 'rect') {
        setCurrentShape({
          ...currentShape,
          width: pos.x - currentShape.x,
          height: pos.y - currentShape.y,
        })
      } else if (currentShape.type === 'circle') {
        const dx = pos.x - currentShape.x
        const dy = pos.y - currentShape.y
        setCurrentShape({
          ...currentShape,
          radius: Math.sqrt(dx * dx + dy * dy),
        })
      }
    },
    [isDrawing, currentShape]
  )

  // Start a new shape when the pointer goes down on the Stage background
  const handlePointerDown = useCallback(
    (e: KonvaEventObject<PointerEvent>) => {
      const stage = e.target.getStage()
      if (!stage) return
      const pos = stage.getPointerPosition()
      if (!pos) return

      // Only start drawing if clicking on the Stage itself, not a shape
      if (e.target !== stage) return

      setIsDrawing(true)
      const newShape: ShapeData = {
        id: generateId(),
        type: tool,
        x: pos.x,
        y: pos.y,
        width: tool === 'rect' ? 0 : undefined,
        height: tool === 'rect' ? 0 : undefined,
        radius: tool === 'circle' ? 0 : undefined,
        points: tool === 'line' ? [pos.x, pos.y] : undefined,
        fill: tool === 'line' ? 'transparent' : getRandomColor(),
        stroke: '#1e293b',
        strokeWidth: 2,
      }
      setCurrentShape(newShape)
    },
    [tool]
  )

  // Finalize the shape and add it to the shapes array
  const handlePointerUp = useCallback(() => {
    if (!isDrawing || !currentShape) return
    setIsDrawing(false)
    setShapes((prev) => [...prev, currentShape])
    setCurrentShape(null)
  }, [isDrawing, currentShape])

  // Update a shape's position after dragging it
  const handleDragEnd = useCallback(
    (e: KonvaEventObject<DragEvent>, shapeId: string) => {
      setShapes((prev) =>
        prev.map((s) =>
          s.id === shapeId ? { ...s, x: e.target.x(), y: e.target.y() } : s
        )
      )
    },
    []
  )

What does this code do?

This top section defines the shape data model and the three pointer handlers that power drawing.

  • ShapeData describes every shape on the canvas: its position, dimensions, color, and type.
  • handlePointerDown fires when you click. It creates a new shape at the click position with zero size.
  • handlePointerMove fires as you drag. It expands the in-progress shape (width/height for rectangles, radius for circles, additional points for freehand lines).
  • handlePointerUp fires when you release. It pushes the finished shape into the shapes array and clears the in-progress shape.
  • handleDragEnd updates a shape's stored position after you drag it to a new spot.
Render shapes on the canvas

The handlers create shape data, but nothing appears on screen until you render those shapes as Konva components.

  • Still in src/Canvas.tsx, add the JSX return statement directly below the handleDragEnd function (still inside the Canvas component):
  return (
    <div style={{ width: '100vw', height: '100vh', overflow: 'hidden' }}>
      <div
        style={{
          position: 'absolute',
          top: 10,
          left: 10,
          zIndex: 10,
          display: 'flex',
          gap: '8px',
          alignItems: 'center',
          background: '#fff',
          padding: '8px 12px',
          borderRadius: '8px',
          boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
        }}
      >
        <button
          onClick={() => setTool('rect')}
          style={{
            padding: '6px 12px',
            background: tool === 'rect' ? '#3b82f6' : '#e5e7eb',
            color: tool === 'rect' ? '#fff' : '#1f2937',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          Rectangle
        </button>
        <button
          onClick={() => setTool('circle')}
          style={{
            padding: '6px 12px',
            background: tool === 'circle' ? '#3b82f6' : '#e5e7eb',
            color: tool === 'circle' ? '#fff' : '#1f2937',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          Circle
        </button>
        <button
          onClick={() => setTool('line')}
          style={{
            padding: '6px 12px',
            background: tool === 'line' ? '#3b82f6' : '#e5e7eb',
            color: tool === 'line' ? '#fff' : '#1f2937',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          Freehand
        </button>
      </div>

      <Stage
        ref={stageRef}
        width={window.innerWidth}
        height={window.innerHeight}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
      >
        <Layer>
          {/* Render all finished shapes */}
          {shapes.map((shape) => {
            if (shape.type === 'rect') {
              return (
                <Rect
                  key={shape.id}
                  x={shape.x}
                  y={shape.y}
                  width={shape.width}
                  height={shape.height}
                  fill={shape.fill}
                  stroke={shape.stroke}
                  strokeWidth={shape.strokeWidth}
                  draggable
                  onDragEnd={(e) => handleDragEnd(e, shape.id)}
                />
              )
            }
            if (shape.type === 'circle') {
              return (
                <Circle
                  key={shape.id}
                  x={shape.x}
                  y={shape.y}
                  radius={shape.radius}
                  fill={shape.fill}
                  stroke={shape.stroke}
                  strokeWidth={shape.strokeWidth}
                  draggable
                  onDragEnd={(e) => handleDragEnd(e, shape.id)}
                />
              )
            }
            if (shape.type === 'line') {
              return (
                <Line
                  key={shape.id}
                  points={shape.points}
                  stroke={shape.stroke}
                  strokeWidth={shape.strokeWidth}
                  lineCap="round"
                  lineJoin="round"
                  draggable
                  onDragEnd={(e) => handleDragEnd(e, shape.id)}
                />
              )
            }
            return null
          })}
          {/* Render the in-progress shape with reduced opacity */}
          {currentShape && currentShape.type === 'rect' && (
            <Rect
              x={currentShape.x}
              y={currentShape.y}
              width={currentShape.width}
              height={currentShape.height}
              fill={currentShape.fill}
              stroke={currentShape.stroke}
              strokeWidth={currentShape.strokeWidth}
              opacity={0.6}
            />
          )}
          {currentShape && currentShape.type === 'circle' && (
            <Circle
              x={currentShape.x}
              y={currentShape.y}
              radius={currentShape.radius}
              fill={currentShape.fill}
              stroke={currentShape.stroke}
              strokeWidth={currentShape.strokeWidth}
              opacity={0.6}
            />
          )}
          {currentShape && currentShape.type === 'line' && (
            <Line
              points={currentShape.points}
              stroke={currentShape.stroke}
              strokeWidth={currentShape.strokeWidth}
              lineCap="round"
              lineJoin="round"
              opacity={0.6}
            />
          )}
        </Layer>
      </Stage>
    </div>
  )
}

What does this rendering code do?

The JSX maps your shapes data into visible Konva components.

  • The shapes.map() loop checks each shape's type and renders the matching Konva component (<Rect>, <Circle>, or <Line>).
  • Each shape has draggable and an onDragEnd handler so you can reposition shapes after drawing them.
  • The currentShape section renders the in-progress shape at 60% opacity so you see a preview while dragging.
  • The Stage receives onPointerDown, onPointerMove, and onPointerUp to wire up the drawing gesture.
  • Save src/Canvas.tsx.

Seeing a blank screen or errors?

  • Make sure both code chunks are inside the same Canvas function. The first chunk ends after handleDragEnd and the second chunk is the return (...) statement that closes the function with }.
  • Check that you have the closing } after the return block to close the Canvas function.
  • If you see a TypeScript error about KonvaEventObject, make sure your import line includes import { KonvaEventObject } from 'konva/lib/Node'.

Still stuck? Help me debug my Canvas.tsx file. I replaced the contents but I'm seeing errors or a blank screen.

Verify your drawing canvas
  • Return to your browser at http://localhost:5173. The page should hot-reload automatically.
  • Click the Rectangle button in the toolbar, then click and drag on the canvas.
  • A colored rectangle should appear as you drag. Release the mouse to finalize it.
  • Switch to Circle and draw a circle by clicking and dragging outward.
  • Switch to Freehand and draw a squiggly line.
  • Click on any finished shape and drag it to a new position.

You should see shapes in random colors from the palette. Shapes render at 60% opacity while you're drawing, then solidify when you release.

Why draggable needs onDragEnd

When a Konva shape has draggable and a defined position, you must handle onDragEnd to sync the new position back to React state. Without it, React would reset the shape to its old coordinates on the next render.

✔️ Awesome, I've got everything!

Great. Double check you've saved src/Canvas.tsx and all three shape types draw correctly.

ⓧ I'd like to double check the full code

Here is the complete src/Canvas.tsx at this point:

import { useState, useRef, useCallback } from 'react'
import { Stage, Layer, Rect, Circle, Line } from 'react-konva'
import { KonvaEventObject } from 'konva/lib/Node'

// Shape data structure for every object on the canvas
interface ShapeData {
  id: string
  type: 'rect' | 'circle' | 'line'
  x: number
  y: number
  width?: number
  height?: number
  radius?: number
  points?: number[]
  fill: string
  stroke: string
  strokeWidth: number
}

type Tool = 'rect' | 'circle' | 'line'

// Five colors shapes can randomly be assigned
const COLORS = ['#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6']

// Generate a short random ID for each shape
function generateId(): string {
  return Math.random().toString(36).substring(2, 10)
}

// Pick a random color from the palette
function getRandomColor(): string {
  return COLORS[Math.floor(Math.random() * COLORS.length)]
}

export function Canvas() {
  const [shapes, setShapes] = useState<ShapeData[]>([])
  const [tool, setTool] = useState<Tool>('rect')
  const [isDrawing, setIsDrawing] = useState(false)
  const [currentShape, setCurrentShape] = useState<ShapeData | null>(null)
  const stageRef = useRef<any>(null)

  // Update the in-progress shape as the pointer moves
  const handlePointerMove = useCallback(
    (e: KonvaEventObject<PointerEvent>) => {
      const stage = e.target.getStage()
      if (!stage) return
      const pos = stage.getPointerPosition()
      if (!pos) return

      if (!isDrawing || !currentShape) return

      if (currentShape.type === 'line') {
        setCurrentShape({
          ...currentShape,
          points: [...(currentShape.points || []), pos.x, pos.y],
        })
      } else if (currentShape.type === 'rect') {
        setCurrentShape({
          ...currentShape,
          width: pos.x - currentShape.x,
          height: pos.y - currentShape.y,
        })
      } else if (currentShape.type === 'circle') {
        const dx = pos.x - currentShape.x
        const dy = pos.y - currentShape.y
        setCurrentShape({
          ...currentShape,
          radius: Math.sqrt(dx * dx + dy * dy),
        })
      }
    },
    [isDrawing, currentShape]
  )

  // Start a new shape when the pointer goes down on the Stage background
  const handlePointerDown = useCallback(
    (e: KonvaEventObject<PointerEvent>) => {
      const stage = e.target.getStage()
      if (!stage) return
      const pos = stage.getPointerPosition()
      if (!pos) return

      // Only start drawing if clicking on the Stage itself, not a shape
      if (e.target !== stage) return

      setIsDrawing(true)
      const newShape: ShapeData = {
        id: generateId(),
        type: tool,
        x: pos.x,
        y: pos.y,
        width: tool === 'rect' ? 0 : undefined,
        height: tool === 'rect' ? 0 : undefined,
        radius: tool === 'circle' ? 0 : undefined,
        points: tool === 'line' ? [pos.x, pos.y] : undefined,
        fill: tool === 'line' ? 'transparent' : getRandomColor(),
        stroke: '#1e293b',
        strokeWidth: 2,
      }
      setCurrentShape(newShape)
    },
    [tool]
  )

  // Finalize the shape and add it to the shapes array
  const handlePointerUp = useCallback(() => {
    if (!isDrawing || !currentShape) return
    setIsDrawing(false)
    setShapes((prev) => [...prev, currentShape])
    setCurrentShape(null)
  }, [isDrawing, currentShape])

  // Update a shape's position after dragging it
  const handleDragEnd = useCallback(
    (e: KonvaEventObject<DragEvent>, shapeId: string) => {
      setShapes((prev) =>
        prev.map((s) =>
          s.id === shapeId ? { ...s, x: e.target.x(), y: e.target.y() } : s
        )
      )
    },
    []
  )

  return (
    <div style={{ width: '100vw', height: '100vh', overflow: 'hidden' }}>
      <div
        style={{
          position: 'absolute',
          top: 10,
          left: 10,
          zIndex: 10,
          display: 'flex',
          gap: '8px',
          alignItems: 'center',
          background: '#fff',
          padding: '8px 12px',
          borderRadius: '8px',
          boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
        }}
      >
        <button
          onClick={() => setTool('rect')}
          style={{
            padding: '6px 12px',
            background: tool === 'rect' ? '#3b82f6' : '#e5e7eb',
            color: tool === 'rect' ? '#fff' : '#1f2937',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          Rectangle
        </button>
        <button
          onClick={() => setTool('circle')}
          style={{
            padding: '6px 12px',
            background: tool === 'circle' ? '#3b82f6' : '#e5e7eb',
            color: tool === 'circle' ? '#fff' : '#1f2937',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          Circle
        </button>
        <button
          onClick={() => setTool('line')}
          style={{
            padding: '6px 12px',
            background: tool === 'line' ? '#3b82f6' : '#e5e7eb',
            color: tool === 'line' ? '#fff' : '#1f2937',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          Freehand
        </button>
      </div>

      <Stage
        ref={stageRef}
        width={window.innerWidth}
        height={window.innerHeight}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
      >
        <Layer>
          {shapes.map((shape) => {
            if (shape.type === 'rect') {
              return (
                <Rect
                  key={shape.id}
                  x={shape.x}
                  y={shape.y}
                  width={shape.width}
                  height={shape.height}
                  fill={shape.fill}
                  stroke={shape.stroke}
                  strokeWidth={shape.strokeWidth}
                  draggable
                  onDragEnd={(e) => handleDragEnd(e, shape.id)}
                />
              )
            }
            if (shape.type === 'circle') {
              return (
                <Circle
                  key={shape.id}
                  x={shape.x}
                  y={shape.y}
                  radius={shape.radius}
                  fill={shape.fill}
                  stroke={shape.stroke}
                  strokeWidth={shape.strokeWidth}
                  draggable
                  onDragEnd={(e) => handleDragEnd(e, shape.id)}
                />
              )
            }
            if (shape.type === 'line') {
              return (
                <Line
                  key={shape.id}
                  points={shape.points}
                  stroke={shape.stroke}
                  strokeWidth={shape.strokeWidth}
                  lineCap="round"
                  lineJoin="round"
                  draggable
                  onDragEnd={(e) => handleDragEnd(e, shape.id)}
                />
              )
            }
            return null
          })}
          {currentShape && currentShape.type === 'rect' && (
            <Rect
              x={currentShape.x}
              y={currentShape.y}
              width={currentShape.width}
              height={currentShape.height}
              fill={currentShape.fill}
              stroke={currentShape.stroke}
              strokeWidth={currentShape.strokeWidth}
              opacity={0.6}
            />
          )}
          {currentShape && currentShape.type === 'circle' && (
            <Circle
              x={currentShape.x}
              y={currentShape.y}
              radius={currentShape.radius}
              fill={currentShape.fill}
              stroke={currentShape.stroke}
              strokeWidth={currentShape.strokeWidth}
              opacity={0.6}
            />
          )}
          {currentShape && currentShape.type === 'line' && (
            <Line
              points={currentShape.points}
              stroke={currentShape.stroke}
              strokeWidth={currentShape.strokeWidth}
              lineCap="round"
              lineJoin="round"
              opacity={0.6}
            />
          )}
        </Layer>
      </Stage>
    </div>
  )
}

Your canvas can now create and display shapes. Everything lives in local React state for now, which means it only works in a single browser tab. Next up, you'll connect a CRDT collaboration layer so shapes sync between multiple tabs in real-time.

Add the CRDT Collaboration Layer

You have a working drawing canvas, but everything lives in a single browser tab. Close the tab and your shapes vanish. Open a second tab and it has no idea what the first tab drew.

In this step, you will build the collaboration brain of your app. You will create a shared CRDT document using Yjs that multiple tabs (or peers) can write to simultaneously without conflicts. Every shape becomes a Y.Map inside a shared Y.Array, synced peer-to-peer via WebRTC and persisted locally in IndexedDB.

In this step, get ready to:
  • Create the collaboration module with CRDT state and providers.
  • Replace local shape state with the shared Yjs array.
  • Subscribe to remote changes so shapes sync across browser tabs.
Create the collaboration module

Your canvas currently stores shapes in local React state. To make shapes collaborative, you need a shared data structure that every peer can write to independently. You will put all CRDT logic in a single file so it acts as a module-level singleton (one document, one set of providers).

  • Create a new file called src/collaboration.ts inside your collab-canvas project.
  • Add the imports, the ShapeData interface, and the CRDT initialization code by pasting this:
import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'
import { IndexeddbPersistence } from 'y-indexeddb'

export interface ShapeData {
  id: string
  type: 'rect' | 'circle' | 'line'
  x: number
  y: number
  width?: number
  height?: number
  radius?: number
  points?: number[]
  fill: string
  stroke: string
  strokeWidth: number
}

// Create a single Yjs document that all providers share
const ydoc = new Y.Doc()
const roomName = 'collab-canvas-room'

// Persist document state to the browser's IndexedDB
const persistence = new IndexeddbPersistence(roomName, ydoc)
// Connect to other peers via WebRTC for real-time sync
const provider = new WebrtcProvider(roomName, ydoc)

// Export the awareness instance for cursor sharing later
export const awareness = provider.awareness
// Define the shared array that stores all shapes
export const yShapes = ydoc.getArray<Y.Map<unknown>>('shapes')

What does this code do?

Y.Doc is the root CRDT document. Everything collaborative lives inside it.

IndexeddbPersistence saves the document to the browser's local database. This means shapes survive page reloads even without a network connection.

WebrtcProvider connects to other peers in the same room using WebRTC. Changes propagate directly between browsers with no central server.

yShapes is a shared Y.Array where each element will be a Y.Map representing one shape.

  • Below the initialization code, add the addShape function that converts a plain JavaScript shape object into CRDT data:
export function addShape(shape: ShapeData): void {
  // Create a new Y.Map to hold this shape's properties
  const yMap = new Y.Map<unknown>()
  Object.entries(shape).forEach(([key, value]) => {
    if (Array.isArray(value)) {
      // Arrays (like freehand points) need their own Y.Array
      const yArr = new Y.Array<number>()
      yArr.push(value)
      yMap.set(key, yArr)
    } else {
      yMap.set(key, value)
    }
  })
  // Push the Y.Map into the shared array
  yShapes.push([yMap])
}

Why convert to Y.Map instead of pushing plain objects?

Plain objects are not CRDT-aware. A Y.Map lets Yjs track each property independently. If two users move different shapes at the same time, Yjs merges both changes without conflict because each shape is its own Y.Map entry.

  • Add the remaining helper functions and the final exports below addShape:
export function updateShape(id: string, updates: Partial<ShapeData>): void {
  // Find the shape by its id and update only the changed properties
  for (let i = 0; i < yShapes.length; i++) {
    const yMap = yShapes.get(i)
    if (yMap.get('id') === id) {
      Object.entries(updates).forEach(([key, value]) => {
        if (Array.isArray(value)) {
          const yArr = new Y.Array<number>()
          yArr.push(value)
          yMap.set(key, yArr)
        } else {
          yMap.set(key, value)
        }
      })
      break
    }
  }
}

export function getShapesFromYArray(): ShapeData[] {
  // Read all Y.Maps from the shared array and convert to plain objects
  const shapes: ShapeData[] = []
  for (let i = 0; i < yShapes.length; i++) {
    const yMap = yShapes.get(i)
    const shape: Record<string, unknown> = {}
    yMap.forEach((value, key) => {
      if (value instanceof Y.Array) {
        shape[key] = value.toArray()
      } else {
        shape[key] = value
      }
    })
    shapes.push(shape as unknown as ShapeData)
  }
  return shapes
}

export function onShapesChange(callback: () => void): () => void {
  // observeDeep fires when the array OR any child Y.Map changes
  const observer = () => callback()
  yShapes.observeDeep(observer)
  return () => yShapes.unobserveDeep(observer)
}

export { provider, persistence, ydoc }

What do these functions do?

  • updateShape finds a shape by id and patches only the properties that changed (like position after a drag).
  • getShapesFromYArray converts the CRDT data back into plain JavaScript objects so React can render them.
  • onShapesChange uses observeDeep to listen for any change to the array or its children. It returns an unsubscribe function for cleanup.
  • Save src/collaboration.ts.

You will not see a visible change yet because the Canvas component still uses local state. The next substep wires it up.

Seeing TypeScript errors in your editor?

Make sure you saved the file with a .ts extension (not .tsx). This file has no JSX, so it only needs .ts.

If you see red squiggles under the imports, your dev server may need a moment to resolve the new packages. Try stopping and re-running npm run dev.

help me fix TypeScript errors in my collaboration.ts file

Replace local state with the shared CRDT array

Your Canvas component currently pushes finished shapes into a local React state array. Now you will swap that out so finished shapes go into the shared Y.Array instead.

  • In src/Canvas.tsx, update your imports at the top of the file to bring in the collaboration functions. Find the line where you import ShapeData (or where you define it locally) and replace it with this import block:
import {
  ShapeData,
  addShape,
  updateShape,
  getShapesFromYArray,
  onShapesChange,
} from './collaboration'

What changed?

You removed the local ShapeData interface from this file. It now lives in collaboration.ts as the single source of truth for the shape type. The helper functions will handle all reads and writes to the CRDT.

  • In your handlePointerUp function, find the line where you push the finished shape into local state. Replace it with a call to addShape. The function should look like this:
  const handlePointerUp = useCallback(() => {
    if (!isDrawing || !currentShape) return
    setIsDrawing(false)
    // Push the shape into the shared CRDT array instead of local state
    addShape(currentShape)
    setCurrentShape(null)
  }, [isDrawing, currentShape])
  • In your handleDragEnd function, replace the local state update with a call to updateShape. It should look like this:
  const handleDragEnd = useCallback(
    (e: KonvaEventObject<DragEvent>, shapeId: string) => {
      // Sync the new position into the CRDT
      updateShape(shapeId, { x: e.target.x(), y: e.target.y() })
    },
    []
  )

Why not update local state directly anymore?

When you call addShape or updateShape, the change goes into the shared Yjs document. The observer you will set up next detects that change and updates React state. This means the same code path handles both local and remote changes.

  • Save src/Canvas.tsx.

Your canvas will not render shapes yet because nothing is reading from the CRDT array back into React state. The next substep connects the two.

Shapes disappear when you draw?

This is expected at this point. You removed the local state push but have not added the observer yet. Shapes are being saved to the CRDT but React does not know to re-render. The next substep fixes this.

help me understand why my shapes vanish after switching to addShape

Subscribe to CRDT changes and verify real-time sync

The final piece is a subscription that watches the shared array for changes. Whenever any peer adds or updates a shape, your React state refreshes automatically.

  • In your Canvas component, add this useEffect right after the state declarations (the useState lines). This reads the initial shapes and subscribes to future changes:
  useEffect(() => {
    // Load any shapes already in the CRDT (e.g. from IndexedDB)
    setShapes(getShapesFromYArray())
    // Re-read shapes whenever the shared array changes
    const unsubscribe = onShapesChange(() => {
      setShapes(getShapesFromYArray())
    })
    return unsubscribe
  }, [])

What does this useEffect do?

  • On mount, it calls getShapesFromYArray() to hydrate React state with any shapes already in the CRDT (loaded from IndexedDB).
  • It registers an observeDeep listener via onShapesChange. This fires whenever the array or any child Y.Map changes, whether from this tab or a remote peer.
  • The cleanup function unsubscribes when the component unmounts.
  • Save src/Canvas.tsx.
  • Open two browser tabs at http://localhost:5173.
  • Draw a shape in one tab. Watch it appear in the other tab within a second.

You should see shapes sync between both tabs in real time. Dragging a shape in one tab updates its position in the other tab too.

Shapes not syncing between tabs?

Make sure both tabs are at the exact same URL (http://localhost:5173). The WebRTC provider connects peers by room name, which is collab-canvas-room in your code.

Check your browser console for errors. If you see a message about signaling, the public WebRTC signaling server may be temporarily slow. Wait a few seconds and try drawing again.

my shapes are not syncing between browser tabs

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/collaboration.ts and src/Canvas.tsx.

ⓧ I'd like to double check the full code

Here is src/collaboration.ts in full:

import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'
import { IndexeddbPersistence } from 'y-indexeddb'

export interface ShapeData {
  id: string
  type: 'rect' | 'circle' | 'line'
  x: number
  y: number
  width?: number
  height?: number
  radius?: number
  points?: number[]
  fill: string
  stroke: string
  strokeWidth: number
}

const ydoc = new Y.Doc()
const roomName = 'collab-canvas-room'

const persistence = new IndexeddbPersistence(roomName, ydoc)
const provider = new WebrtcProvider(roomName, ydoc)

export const awareness = provider.awareness
export const yShapes = ydoc.getArray<Y.Map<unknown>>('shapes')

export function addShape(shape: ShapeData): void {
  const yMap = new Y.Map<unknown>()
  Object.entries(shape).forEach(([key, value]) => {
    if (Array.isArray(value)) {
      const yArr = new Y.Array<number>()
      yArr.push(value)
      yMap.set(key, yArr)
    } else {
      yMap.set(key, value)
    }
  })
  yShapes.push([yMap])
}

export function updateShape(id: string, updates: Partial<ShapeData>): void {
  for (let i = 0; i < yShapes.length; i++) {
    const yMap = yShapes.get(i)
    if (yMap.get('id') === id) {
      Object.entries(updates).forEach(([key, value]) => {
        if (Array.isArray(value)) {
          const yArr = new Y.Array<number>()
          yArr.push(value)
          yMap.set(key, yArr)
        } else {
          yMap.set(key, value)
        }
      })
      break
    }
  }
}

export function getShapesFromYArray(): ShapeData[] {
  const shapes: ShapeData[] = []
  for (let i = 0; i < yShapes.length; i++) {
    const yMap = yShapes.get(i)
    const shape: Record<string, unknown> = {}
    yMap.forEach((value, key) => {
      if (value instanceof Y.Array) {
        shape[key] = value.toArray()
      } else {
        shape[key] = value
      }
    })
    shapes.push(shape as unknown as ShapeData)
  }
  return shapes
}

export function onShapesChange(callback: () => void): () => void {
  const observer = () => callback()
  yShapes.observeDeep(observer)
  return () => yShapes.unobserveDeep(observer)
}

export { provider, persistence, ydoc }

Here is src/Canvas.tsx in full:

import { useEffect, useState, useRef, useCallback } from 'react'
import { Stage, Layer, Rect, Circle, Line } from 'react-konva'
import { KonvaEventObject } from 'konva/lib/Node'
import {
  ShapeData,
  addShape,
  updateShape,
  getShapesFromYArray,
  onShapesChange,
} from './collaboration'

type Tool = 'rect' | 'circle' | 'line'

const COLORS = ['#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6']

function generateId(): string {
  return Math.random().toString(36).substring(2, 10)
}

function getRandomColor(): string {
  return COLORS[Math.floor(Math.random() * COLORS.length)]
}

export function Canvas() {
  const [shapes, setShapes] = useState<ShapeData[]>([])
  const [tool, setTool] = useState<Tool>('rect')
  const [isDrawing, setIsDrawing] = useState(false)
  const [currentShape, setCurrentShape] = useState<ShapeData | null>(null)
  const stageRef = useRef<any>(null)

  useEffect(() => {
    setShapes(getShapesFromYArray())
    const unsubscribe = onShapesChange(() => {
      setShapes(getShapesFromYArray())
    })
    return unsubscribe
  }, [])

  const handlePointerMove = useCallback(
    (e: KonvaEventObject<PointerEvent>) => {
      const stage = e.target.getStage()
      if (!stage) return
      const pos = stage.getPointerPosition()
      if (!pos) return

      if (!isDrawing || !currentShape) return

      if (currentShape.type === 'line') {
        setCurrentShape({
          ...currentShape,
          points: [...(currentShape.points || []), pos.x, pos.y],
        })
      } else if (currentShape.type === 'rect') {
        setCurrentShape({
          ...currentShape,
          width: pos.x - currentShape.x,
          height: pos.y - currentShape.y,
        })
      } else if (currentShape.type === 'circle') {
        const dx = pos.x - currentShape.x
        const dy = pos.y - currentShape.y
        setCurrentShape({
          ...currentShape,
          radius: Math.sqrt(dx * dx + dy * dy),
        })
      }
    },
    [isDrawing, currentShape]
  )

  const handlePointerDown = useCallback(
    (e: KonvaEventObject<PointerEvent>) => {
      const stage = e.target.getStage()
      if (!stage) return
      const pos = stage.getPointerPosition()
      if (!pos) return

      if (e.target !== stage) return

      setIsDrawing(true)
      const newShape: ShapeData = {
        id: generateId(),
        type: tool,
        x: pos.x,
        y: pos.y,
        width: tool === 'rect' ? 0 : undefined,
        height: tool === 'rect' ? 0 : undefined,
        radius: tool === 'circle' ? 0 : undefined,
        points: tool === 'line' ? [pos.x, pos.y] : undefined,
        fill: tool === 'line' ? 'transparent' : getRandomColor(),
        stroke: '#1e293b',
        strokeWidth: 2,
      }
      setCurrentShape(newShape)
    },
    [tool]
  )

  const handlePointerUp = useCallback(() => {
    if (!isDrawing || !currentShape) return
    setIsDrawing(false)
    addShape(currentShape)
    setCurrentShape(null)
  }, [isDrawing, currentShape])

  const handleDragEnd = useCallback(
    (e: KonvaEventObject<DragEvent>, shapeId: string) => {
      updateShape(shapeId, { x: e.target.x(), y: e.target.y() })
    },
    []
  )

  return (
    <div style={{ width: '100vw', height: '100vh', overflow: 'hidden' }}>
      <div
        style={{
          position: 'absolute',
          top: 10,
          left: 10,
          zIndex: 10,
          display: 'flex',
          gap: '8px',
          alignItems: 'center',
          background: '#fff',
          padding: '8px 12px',
          borderRadius: '8px',
          boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
        }}
      >
        <button
          onClick={() => setTool('rect')}
          style={{
            padding: '6px 12px',
            background: tool === 'rect' ? '#3b82f6' : '#e5e7eb',
            color: tool === 'rect' ? '#fff' : '#1f2937',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          Rectangle
        </button>
        <button
          onClick={() => setTool('circle')}
          style={{
            padding: '6px 12px',
            background: tool === 'circle' ? '#3b82f6' : '#e5e7eb',
            color: tool === 'circle' ? '#fff' : '#1f2937',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          Circle
        </button>
        <button
          onClick={() => setTool('line')}
          style={{
            padding: '6px 12px',
            background: tool === 'line' ? '#3b82f6' : '#e5e7eb',
            color: tool === 'line' ? '#fff' : '#1f2937',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          Freehand
        </button>
      </div>

      <Stage
        ref={stageRef}
        width={window.innerWidth}
        height={window.innerHeight}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
      >
        <Layer>
          {shapes.map((shape) => {
            if (shape.type === 'rect') {
              return (
                <Rect
                  key={shape.id}
                  x={shape.x}
                  y={shape.y}
                  width={shape.width}
                  height={shape.height}
                  fill={shape.fill}
                  stroke={shape.stroke}
                  strokeWidth={shape.strokeWidth}
                  draggable
                  onDragEnd={(e) => handleDragEnd(e, shape.id)}
                />
              )
            }
            if (shape.type === 'circle') {
              return (
                <Circle
                  key={shape.id}
                  x={shape.x}
                  y={shape.y}
                  radius={shape.radius}
                  fill={shape.fill}
                  stroke={shape.stroke}
                  strokeWidth={shape.strokeWidth}
                  draggable
                  onDragEnd={(e) => handleDragEnd(e, shape.id)}
                />
              )
            }
            if (shape.type === 'line') {
              return (
                <Line
                  key={shape.id}
                  points={shape.points}
                  stroke={shape.stroke}
                  strokeWidth={shape.strokeWidth}
                  lineCap="round"
                  lineJoin="round"
                  draggable
                  onDragEnd={(e) => handleDragEnd(e, shape.id)}
                />
              )
            }
            return null
          })}
          {currentShape && currentShape.type === 'rect' && (
            <Rect
              x={currentShape.x}
              y={currentShape.y}
              width={currentShape.width}
              height={currentShape.height}
              fill={currentShape.fill}
              stroke={currentShape.stroke}
              strokeWidth={currentShape.strokeWidth}
              opacity={0.6}
            />
          )}
          {currentShape && currentShape.type === 'circle' && (
            <Circle
              x={currentShape.x}
              y={currentShape.y}
              radius={currentShape.radius}
              fill={currentShape.fill}
              stroke={currentShape.stroke}
              strokeWidth={currentShape.strokeWidth}
              opacity={0.6}
            />
          )}
          {currentShape && currentShape.type === 'line' && (
            <Line
              points={currentShape.points}
              stroke={currentShape.stroke}
              strokeWidth={currentShape.strokeWidth}
              lineCap="round"
              lineJoin="round"
              opacity={0.6}
            />
          )}
        </Layer>
      </Stage>
    </div>
  )
}

Your canvas is now collaborative. Next up, you will test offline persistence and conflict resolution to see how CRDTs handle the hard problems.

Test Offline Support and Conflict Resolution

In the previous step, you wired your canvas to a CRDT-backed shared array so that shapes sync between browser tabs in real-time. The collaboration layer is live.

Now it's time to prove that your app is truly offline-first. You'll test three scenarios that show how IndexedDB persistence and CRDTs handle the hardest problems in distributed systems: data survival, offline editing, and conflict-free merging.

In this step, get ready to:
  • Verify that shapes persist across page reloads via IndexedDB.
  • Test drawing while offline and confirm shapes sync when reconnected.
  • Prove conflict-free merging by editing two offline tabs simultaneously.
Test that shapes persist across page reloads

The first promise of offline-first is that your data survives even if you close the browser. The IndexeddbPersistence provider you wired up in src/collaboration.ts saves every Yjs document update to the browser's IndexedDB. Let's prove it works.

  • Open your canvas at http://localhost:5173 in your browser.
  • Draw 3-4 shapes on the canvas using different tools (a rectangle, a circle, and a freehand line).
  • Close the browser tab entirely.
  • Open a new tab and navigate back to http://localhost:5173.

Your shapes should still be there, exactly where you left them. The page loaded them from IndexedDB before any network connection was established.

What is IndexedDB?

IndexedDB is a database built into every modern browser. It stores structured data locally on the user's device, and it persists even after the browser is closed.

The y-indexeddb provider automatically writes every change to IndexedDB as it happens. On the next page load, it reads the stored document first and only fetches network updates after that.

Shapes disappeared after reload?

Make sure you are opening the same URL (http://localhost:5173) in the same browser. IndexedDB storage is scoped to the origin (domain + port).

Also check that your dev server is still running. If you restarted it on a different port, the browser treats it as a different origin with a fresh database.

Why are my shapes not persisting after I close the tab?

Test drawing while offline

The second promise is that the app works fully offline. You can disconnect from the network, keep drawing, and everything merges cleanly when you reconnect.

  • Open http://localhost:5173 in a browser tab.
  • Open Chrome DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
  • Click the Network tab in DevTools.
  • Check the Offline checkbox near the top of the Network panel.

Your browser is now simulating a complete network disconnection. The connection status dot in your toolbar should turn red.

  • Draw 2-3 new shapes on the canvas while offline.
  • Uncheck the Offline checkbox to go back online.

If you have a second tab open at the same URL, your offline shapes will appear there within a few seconds. The WebRTC provider reconnects and syncs the changes that accumulated while you were offline.

Why does this work without a server?

Yjs stores every change as an incremental update. While offline, these updates queue in memory and IndexedDB. When the WebRTC connection re-establishes, the peers exchange only the updates the other side is missing.

No central server decides what the "truth" is. Each peer holds a complete copy of the document and merges incoming updates using the CRDT algorithm.

Shapes not syncing after going back online?

WebRTC reconnection can take a few seconds. Wait 5-10 seconds after unchecking Offline, then check the other tab.

If shapes still don't appear, try refreshing the second tab. The IndexedDB layer ensures nothing is lost.

Help me debug why my offline shapes aren't syncing to other tabs.

Prove conflict-free merging with two offline tabs

This is the ultimate test of CRDTs. Two users make completely independent changes while disconnected from each other. When they reconnect, both sets of changes merge automatically with zero conflicts.

  • Open two browser tabs side by side, both at http://localhost:5173.
  • Open Chrome DevTools in both tabs (Cmd+Option+I on macOS, Ctrl+Shift+I on Windows).
  • In both tabs, go to the Network panel and check Offline.
  • In Tab A, draw a large red rectangle in the top-left corner.
  • In Tab B, draw a circle in the bottom-right corner.

Right now, each tab only sees its own shape. They have no way to communicate.

  • Uncheck Offline in both tabs to bring them back online.
  • Wait 5-10 seconds for WebRTC to re-establish the peer connection.

Both shapes should now appear on both canvases. The rectangle from Tab A and the circle from Tab B merged without any conflict dialog, overwrite, or data loss.

How do CRDTs avoid conflicts?

Each shape lives in its own Y.Map inside a shared Y.Array. When two peers add different shapes while offline, the CRDT merge algorithm interleaves both entries into the array based on their unique internal IDs.

Conflicts only arise when two peers edit the exact same property of the same shape at the same time. Even then, CRDTs resolve deterministically. Every peer converges to the same final state without coordination.

This is how Figma and Notion work

Production collaboration tools like Figma, Notion, and Linear use similar conflict-free data structures under the hood. You just built the same fundamental pattern from scratch.

Only seeing one tab's shapes after reconnecting?

Make sure you unchecked Offline in both tabs. If only one tab goes online, it has no peer to sync with yet.

Try refreshing both tabs. IndexedDB stores all local changes, and the WebRTC provider will sync the full state on reconnection.

Why aren't my two offline tabs merging their shapes?

You just proved the three pillars of offline-first architecture: persistence, offline editing, and conflict-free merging. Next up, you'll add live cursor awareness so collaborators can see each other's pointer positions in real-time.

Add Live Cursor Awareness

Your canvas now syncs shapes between tabs and handles offline editing with automatic conflict resolution. The next piece of the collaboration puzzle is presence.

In apps like Figma or Miro, you can see other people's cursors moving around in real-time. This is powered by a lightweight protocol that broadcasts ephemeral state (like cursor positions) without storing it permanently. Yjs calls this the Awareness CRDT.

In this step, get ready to:
  • Broadcast your cursor position to all connected peers.
  • Create a Cursors component that renders remote users' pointers on the canvas.
  • Add a connection status indicator showing online state and peer count.
Broadcast your cursor position and set user identity

The awareness object is already exported from collaboration.ts. Each client can set fields on their own awareness state, and every connected peer receives those updates instantly.

What is the Awareness CRDT?

The Awareness protocol is a lightweight CRDT that manages ephemeral user state. Unlike the Y.Doc (which persists shapes to IndexedDB), awareness state is temporary. If you close a tab, that user's awareness disappears after 30 seconds.

Editor bindings commonly use a cursor field for pointer positions and a user field for display names and colors.

  • In src/Canvas.tsx, add awareness to your import from ./collaboration.
  • Add two new state variables for connection tracking, and a useEffect that sets up user identity and peer monitoring by pasting this code after your existing shapes useEffect:
const [connected, setConnected] = useState(false)
const [peerCount, setPeerCount] = useState(1)
  • Add these state declarations alongside your existing shapes, tool, isDrawing, and currentShape state.
  • Then add this useEffect after the shapes subscription useEffect:
useEffect(() => {
  const userColor = getRandomColor()
  awareness.setLocalStateField('user', {
    name: `User-${awareness.clientID.toString().slice(-4)}`,
    color: userColor,
  })

  const updatePeers = () => {
    setPeerCount(awareness.getStates().size)
    setConnected(awareness.getStates().size > 0)
  }
  awareness.on('change', updatePeers)
  updatePeers()

  return () => {
    awareness.off('change', updatePeers)
  }
}, [])

What does this code do?

When the component mounts, it picks a random color and sets a user name based on the last 4 digits of the client ID. This information is broadcast to all peers via the awareness protocol.

The updatePeers function reads how many awareness states exist (one per connected peer) and updates the local connected and peerCount state accordingly.

Now you need to broadcast your cursor position every time the pointer moves.

  • In your handlePointerMove function, find the line that reads the pointer position:
const pos = stage.getPointerPosition()
if (!pos) return
  • Immediately after this null check, add the cursor broadcast so the function now looks like this:
const pos = stage.getPointerPosition()
if (!pos) return

awareness.setLocalStateField('cursor', { x: pos.x, y: pos.y })

What does this line do?

Every time the pointer moves, this line publishes the cursor's x/y coordinates to all connected peers. Remote clients receive this update through the awareness protocol and can render your cursor on their canvas.

  • Save src/Canvas.tsx.

You won't see a change yet. The next chunk creates the component that renders remote cursors.

Create the Cursors component

Each peer broadcasts their cursor position through awareness. Now you need a component that listens for those positions and draws them on the canvas.

  • Create a new file src/Cursors.tsx and paste the following code:
import { useEffect, useState } from 'react'
import { Circle, Text, Group } from 'react-konva'
import { awareness } from './collaboration'

interface CursorState {
  clientId: number
  user: { name: string; color: string }
  cursor: { x: number; y: number }
}

export function Cursors() {
  const [cursors, setCursors] = useState<CursorState[]>([])

  useEffect(() => {
    const updateCursors = () => {
      const states: CursorState[] = []
      awareness.getStates().forEach((state, clientId) => {
        // Only render cursors from OTHER peers, not yourself
        if (clientId !== awareness.clientID && state.cursor) {
          states.push({
            clientId,
            user: state.user || { name: 'Anonymous', color: '#999' },
            cursor: state.cursor,
          })
        }
      })
      setCursors(states)
    }

    awareness.on('change', updateCursors)
    return () => {
      awareness.off('change', updateCursors)
    }
  }, [])

  return (
    <>
      {cursors.map((c) => (
        <Group key={c.clientId} x={c.cursor.x} y={c.cursor.y}>
          <Circle radius={5} fill={c.user.color} opacity={0.8} />
          <Text
            text={c.user.name}
            fontSize={12}
            fill={c.user.color}
            offsetX={-10}
            offsetY={-10}
          />
        </Group>
      ))}
    </>
  )
}

What does this component do?

On every awareness change event, it loops through all connected peers' states. It skips the local client (you don't need to see your own cursor rendered twice) and collects each remote peer's cursor position, name, and color.

For each remote cursor, it renders a Konva Group positioned at the peer's coordinates. Inside the group, a small colored Circle marks the pointer and a Text label shows the user's name.

  • Save src/Cursors.tsx.

You won't see cursors appear yet because the Cursors component hasn't been rendered inside the canvas Layer.

✔️ Awesome, I've got everything!

Great. Make sure you've saved src/Cursors.tsx.

ⓧ I'd like to double check the full code

import { useEffect, useState } from 'react'
import { Circle, Text, Group } from 'react-konva'
import { awareness } from './collaboration'

interface CursorState {
  clientId: number
  user: { name: string; color: string }
  cursor: { x: number; y: number }
}

export function Cursors() {
  const [cursors, setCursors] = useState<CursorState[]>([])

  useEffect(() => {
    const updateCursors = () => {
      const states: CursorState[] = []
      awareness.getStates().forEach((state, clientId) => {
        if (clientId !== awareness.clientID && state.cursor) {
          states.push({
            clientId,
            user: state.user || { name: 'Anonymous', color: '#999' },
            cursor: state.cursor,
          })
        }
      })
      setCursors(states)
    }

    awareness.on('change', updateCursors)
    return () => {
      awareness.off('change', updateCursors)
    }
  }, [])

  return (
    <>
      {cursors.map((c) => (
        <Group key={c.clientId} x={c.cursor.x} y={c.cursor.y}>
          <Circle radius={5} fill={c.user.color} opacity={0.8} />
          <Text
            text={c.user.name}
            fontSize={12}
            fill={c.user.color}
            offsetX={-10}
            offsetY={-10}
          />
        </Group>
      ))}
    </>
  )
}
Add the connection status indicator and render cursors

The final piece is wiring the Cursors component into the canvas and giving the user a visual indicator of their connection state.

  • At the top of src/Canvas.tsx, add the Cursors import below your collaboration import:
import { Cursors } from './Cursors'
  • In your toolbar div (the one with the tool buttons), add this connection status indicator after the last button:
<div
  style={{
    marginLeft: '16px',
    display: 'flex',
    alignItems: 'center',
    gap: '6px',
  }}
>
  <div
    style={{
      width: '8px',
      height: '8px',
      borderRadius: '50%',
      background: connected ? '#22c55e' : '#ef4444',
    }}
  />
  <span style={{ fontSize: '13px', color: '#6b7280' }}>
    {peerCount} {peerCount === 1 ? 'user' : 'users'}
  </span>
</div>

What does this UI show?

The small dot turns green when connected and red when offline. The label shows how many peers are in the room (including yourself). This gives users instant feedback about their collaboration state.

  • Finally, render the <Cursors /> component inside your Konva <Layer>, after all your shape rendering code (after the currentShape previews):
<Cursors />
  • Save src/Canvas.tsx.
  • Open two browser tabs side-by-side at http://localhost:5173.
  • Move your mouse in one tab. You should see a colored dot and a name label following your pointer in the other tab.

Don't see the remote cursor?

Make sure both tabs are at exactly the same URL (http://localhost:5173). The WebRTC provider connects peers that share the same room name.

Check that <Cursors /> is inside the <Layer> tag, not outside of it. Konva components must be children of a Layer to render.

If the status indicator shows "1 user" in both tabs, the WebRTC signaling server may be temporarily slow. Wait a few seconds and refresh both tabs.

Help me debug why my remote cursors aren't showing up in my collaborative canvas.

✔️ Awesome, I've got everything!

Great. Double check you've saved both src/Canvas.tsx and src/Cursors.tsx.

ⓧ I'd like to double check the full code

import { useEffect, useState, useRef, useCallback } from 'react'
import { Stage, Layer, Rect, Circle, Line } from 'react-konva'
import { KonvaEventObject } from 'konva/lib/Node'
import {
  ShapeData,
  addShape,
  updateShape,
  getShapesFromYArray,
  onShapesChange,
  awareness,
} from './collaboration'
import { Cursors } from './Cursors'

type Tool = 'rect' | 'circle' | 'line'

const COLORS = ['#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6']

function generateId(): string {
  return Math.random().toString(36).substring(2, 10)
}

function getRandomColor(): string {
  return COLORS[Math.floor(Math.random() * COLORS.length)]
}

export function Canvas() {
  const [shapes, setShapes] = useState<ShapeData[]>([])
  const [tool, setTool] = useState<Tool>('rect')
  const [isDrawing, setIsDrawing] = useState(false)
  const [currentShape, setCurrentShape] = useState<ShapeData | null>(null)
  const [connected, setConnected] = useState(false)
  const [peerCount, setPeerCount] = useState(1)
  const stageRef = useRef<any>(null)

  useEffect(() => {
    setShapes(getShapesFromYArray())
    const unsubscribe = onShapesChange(() => {
      setShapes(getShapesFromYArray())
    })
    return unsubscribe
  }, [])

  useEffect(() => {
    const userColor = getRandomColor()
    awareness.setLocalStateField('user', {
      name: `User-${awareness.clientID.toString().slice(-4)}`,
      color: userColor,
    })

    const updatePeers = () => {
      setPeerCount(awareness.getStates().size)
      setConnected(awareness.getStates().size > 0)
    }
    awareness.on('change', updatePeers)
    updatePeers()

    return () => {
      awareness.off('change', updatePeers)
    }
  }, [])

  const handlePointerMove = useCallback(
    (e: KonvaEventObject<PointerEvent>) => {
      const stage = e.target.getStage()
      if (!stage) return
      const pos = stage.getPointerPosition()
      if (!pos) return

      awareness.setLocalStateField('cursor', { x: pos.x, y: pos.y })

      if (!isDrawing || !currentShape) return

      if (currentShape.type === 'line') {
        setCurrentShape({
          ...currentShape,
          points: [...(currentShape.points || []), pos.x, pos.y],
        })
      } else if (currentShape.type === 'rect') {
        setCurrentShape({
          ...currentShape,
          width: pos.x - currentShape.x,
          height: pos.y - currentShape.y,
        })
      } else if (currentShape.type === 'circle') {
        const dx = pos.x - currentShape.x
        const dy = pos.y - currentShape.y
        setCurrentShape({
          ...currentShape,
          radius: Math.sqrt(dx * dx + dy * dy),
        })
      }
    },
    [isDrawing, currentShape]
  )

  const handlePointerDown = useCallback(
    (e: KonvaEventObject<PointerEvent>) => {
      const stage = e.target.getStage()
      if (!stage) return
      const pos = stage.getPointerPosition()
      if (!pos) return

      if (e.target !== stage) return

      setIsDrawing(true)
      const newShape: ShapeData = {
        id: generateId(),
        type: tool,
        x: pos.x,
        y: pos.y,
        width: tool === 'rect' ? 0 : undefined,
        height: tool === 'rect' ? 0 : undefined,
        radius: tool === 'circle' ? 0 : undefined,
        points: tool === 'line' ? [pos.x, pos.y] : undefined,
        fill: tool === 'line' ? 'transparent' : getRandomColor(),
        stroke: '#1e293b',
        strokeWidth: 2,
      }
      setCurrentShape(newShape)
    },
    [tool]
  )

  const handlePointerUp = useCallback(() => {
    if (!isDrawing || !currentShape) return
    setIsDrawing(false)
    addShape(currentShape)
    setCurrentShape(null)
  }, [isDrawing, currentShape])

  const handleDragEnd = useCallback(
    (e: KonvaEventObject<DragEvent>, shapeId: string) => {
      updateShape(shapeId, { x: e.target.x(), y: e.target.y() })
    },
    []
  )

  return (
    <div style={{ width: '100vw', height: '100vh', overflow: 'hidden' }}>
      <div
        style={{
          position: 'absolute',
          top: 10,
          left: 10,
          zIndex: 10,
          display: 'flex',
          gap: '8px',
          alignItems: 'center',
          background: '#fff',
          padding: '8px 12px',
          borderRadius: '8px',
          boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
        }}
      >
        <button
          onClick={() => setTool('rect')}
          style={{
            padding: '6px 12px',
            background: tool === 'rect' ? '#3b82f6' : '#e5e7eb',
            color: tool === 'rect' ? '#fff' : '#1f2937',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          Rectangle
        </button>
        <button
          onClick={() => setTool('circle')}
          style={{
            padding: '6px 12px',
            background: tool === 'circle' ? '#3b82f6' : '#e5e7eb',
            color: tool === 'circle' ? '#fff' : '#1f2937',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          Circle
        </button>
        <button
          onClick={() => setTool('line')}
          style={{
            padding: '6px 12px',
            background: tool === 'line' ? '#3b82f6' : '#e5e7eb',
            color: tool === 'line' ? '#fff' : '#1f2937',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          Freehand
        </button>
        <div
          style={{
            marginLeft: '16px',
            display: 'flex',
            alignItems: 'center',
            gap: '6px',
          }}
        >
          <div
            style={{
              width: '8px',
              height: '8px',
              borderRadius: '50%',
              background: connected ? '#22c55e' : '#ef4444',
            }}
          />
          <span style={{ fontSize: '13px', color: '#6b7280' }}>
            {peerCount} {peerCount === 1 ? 'user' : 'users'}
          </span>
        </div>
      </div>

      <Stage
        ref={stageRef}
        width={window.innerWidth}
        height={window.innerHeight}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
      >
        <Layer>
          {shapes.map((shape) => {
            if (shape.type === 'rect') {
              return (
                <Rect
                  key={shape.id}
                  x={shape.x}
                  y={shape.y}
                  width={shape.width}
                  height={shape.height}
                  fill={shape.fill}
                  stroke={shape.stroke}
                  strokeWidth={shape.strokeWidth}
                  draggable
                  onDragEnd={(e) => handleDragEnd(e, shape.id)}
                />
              )
            }
            if (shape.type === 'circle') {
              return (
                <Circle
                  key={shape.id}
                  x={shape.x}
                  y={shape.y}
                  radius={shape.radius}
                  fill={shape.fill}
                  stroke={shape.stroke}
                  strokeWidth={shape.strokeWidth}
                  draggable
                  onDragEnd={(e) => handleDragEnd(e, shape.id)}
                />
              )
            }
            if (shape.type === 'line') {
              return (
                <Line
                  key={shape.id}
                  points={shape.points}
                  stroke={shape.stroke}
                  strokeWidth={shape.strokeWidth}
                  lineCap="round"
                  lineJoin="round"
                  draggable
                  onDragEnd={(e) => handleDragEnd(e, shape.id)}
                />
              )
            }
            return null
          })}
          {currentShape && currentShape.type === 'rect' && (
            <Rect
              x={currentShape.x}
              y={currentShape.y}
              width={currentShape.width}
              height={currentShape.height}
              fill={currentShape.fill}
              stroke={currentShape.stroke}
              strokeWidth={currentShape.strokeWidth}
              opacity={0.6}
            />
          )}
          {currentShape && currentShape.type === 'circle' && (
            <Circle
              x={currentShape.x}
              y={currentShape.y}
              radius={currentShape.radius}
              fill={currentShape.fill}
              stroke={currentShape.stroke}
              strokeWidth={currentShape.strokeWidth}
              opacity={0.6}
            />
          )}
          {currentShape && currentShape.type === 'line' && (
            <Line
              points={currentShape.points}
              stroke={currentShape.stroke}
              strokeWidth={currentShape.strokeWidth}
              lineCap="round"
              lineJoin="round"
              opacity={0.6}
            />
          )}
          <Cursors />
        </Layer>
      </Stage>
    </div>
  )
}

Your collaborative canvas now has live presence. Users can see each other's cursors moving in real-time, know how many people are in the room, and tell at a glance whether they're connected or offline. This is the same presence pattern used in production tools like Figma and Notion.

Secret mission

Add Per-User Undo/Redo with UndoManager

Your collaborative canvas works great, but what happens when someone makes a mistake? In this extension, you'll add undo/redo that only reverses YOUR changes, leaving other users' shapes untouched. This is how production tools like Figma handle collaborative history.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The collab-canvas project folder on your Desktop.
  • IndexedDB data stored in your browser (used by y-indexeddb for offline persistence).
  • The Vite dev server running at http://localhost:5173.

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with the collaborative canvas.

  • Your dev server stays running at http://localhost:5173.
  • Your IndexedDB data keeps your shapes persisted across page reloads.
  • Node.js remains installed on your machine for future projects.

Pause - I'll come back to this later

Shut down the running dev server to free up your terminal. Your project files and browser data stay intact for next time.

  • Switch to the terminal where the dev server is running.
  • Press Ctrl+C to stop the server.
  • Your project folder and IndexedDB data remain untouched. When you return, run npm run dev from the collab-canvas folder to start up again.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your project files and clears the browser data.

  • Switch to the terminal where the dev server is running and press Ctrl+C to stop it.
  • Delete the project folder by running:

macOS

cd ~/Desktop
rm -rf collab-canvas

Windows

cd ~\Desktop
rmdir /s /q collab-canvas
  • Clear the IndexedDB data by opening Chrome DevTools (Cmd+Option+I on macOS or Ctrl+Shift+I on Windows).
  • Go to the Application tab, expand IndexedDB in the left sidebar, and delete the collab-canvas-room database.

Nice Work!

Nice Work!

You just built a real collaborative drawing application powered by CRDTs. Multiple users can draw shapes simultaneously, work offline, and merge changes without a single conflict.

You learned how to:

  • Built a Konva-based drawing canvas with React that supports multiple shape tools (rectangles, circles, and freehand lines).
  • Implemented CRDT-backed shared state using Yjs shared types (Y.Array, Y.Map) with peer-to-peer sync via WebRTC and zero server infrastructure.
  • Added offline-first persistence via IndexedDB with automatic conflict-free merging on reconnect, plus live cursor awareness showing other users' pointer positions in real-time.
  • Secret Mission: Added per-user undo/redo using Yjs UndoManager that respects collaborative boundaries.

Ready to quiz yourself?