Overlay is a multiplayer whiteboard — a focused clone of the core Figma/Excalidraw experience. Open the same board in two tabs and you see each other's cursors move live, draw shapes, and edit in perfect sync. It's built on a hand-written HTML5 Canvas 2D render loop (no canvas/whiteboard library) and CRDT sync (Yjs), and it runs entirely on your machine with no accounts.
- Infinite canvas with smooth pan (space-drag / middle-mouse / touch) and
zoom-to-cursor (wheel / pinch), all on a custom
requestAnimationFrameloop that only redraws on change. - Seven tools — select, rectangle, ellipse, line, arrow, freehand pen, text, sticky note — with a clean shape model and a contextual style panel.
- Full manipulation — click/marquee/shift multi-select, move, 8-handle resize, rotate (single shapes and groups), snapping to other shapes' edges and centers with alignment guides, z-order, duplicate, delete.
- History — undo/redo backed by a Yjs
UndoManagerscoped to your edits, so undo never reverts a collaborator's work. - Real-time collaboration — shapes sync through a shared Yjs document; live
interpolated cursors with names and colors; an avatar stack; peer selection
highlights in each peer's color; a shareable
/b/:idURL. - Local-first — the document persists to IndexedDB, so a solo reload keeps your work even offline.
- Export — the board or a selection to PNG (high-res, cropped) or SVG.
- Polished — light/dark themes, responsive + touch, keyboard shortcuts,
visible focus rings,
prefers-reduced-motion, and a steady 60fps with 500+ shapes while a cursor moves.
Requires Node 18+ and pnpm.
pnpm install
# Terminal 1 — the local Yjs sync server (WebSocket, port 1234)
pnpm ws
# Terminal 2 — the app
pnpm devOpen the printed URL, then open it again in a second tab (or paste the Share link) to collaborate. Other scripts:
pnpm build # typecheck (tsc) + production build
pnpm lint # ESLint + Prettier (must be clean; no `any`)
pnpm test # Vitest unit tests (geometry/transform)
pnpm e2e # Playwright, incl. a two-context multiplayer testA single camera { x, y, zoom } where (x, y) is the world coordinate that maps
to screen pixel (0, 0):
screen = (world − camera) · zoom
world = screen / zoom + camera
All shape geometry is stored in world space; only the render step converts to
screen. Zoom-to-cursor keeps the world point under the pointer fixed. Handles and
hit tolerances are defined in screen pixels and divided by zoom, so they stay a
constant size on screen at any zoom. See src/geometry/coords.ts.
src/canvas/RenderLoop.ts is a requestAnimationFrame scheduler with a dirty
flag: when nothing changes it stops scheduling frames entirely (true "redraw only
on change") and re-arms on the next mutation. It also guarantees a synchronous
first paint and falls back to setTimeout when the tab is hidden (background tabs
throttle rAF).
BoardCanvas stacks two canvases:
- a base layer (dot grid + shapes) that re-rasterizes only when the scene, camera or theme changes, and
- an overlay layer (selection outlines, transform handles, marquee, alignment guides, and remote cursors) that animates every frame while peers are moving.
This split is what holds 60fps: 500 static shapes are never re-drawn just because a collaborator's cursor moved.
Shapes live in a Yjs document (src/sync/scene.ts), not in component state —
even in single-player. Each shape is a nested Y.Map, so two people editing
different fields of the same shape (one moving it, one recoloring it) merge without
conflict. A cached snapshot is rebuilt on observeDeep and exposed to React via
useSyncExternalStore and to the render loop via a subscription.
Everything transport-specific sits behind a SyncProvider interface
(src/sync/). YjsSync implements it with y-websocket (network) + y-indexeddb
(persistence); it could be swapped for Liveblocks/PartyKit without touching the app.
Local UI state (camera, active tool, selection, drag state, theme) lives in a small
Zustand store, never in the CRDT.
Presence rides Yjs awareness: each client publishes { user{name,color}, cursor, selection }. Remote cursors are interpolated toward their target each
frame for smooth motion, drawn with a name label in the peer's color; peer
selections are outlined in that same color. The avatar stack re-renders only on
membership/name changes — not on every cursor move.
- Rotation-aware resize. Dragging any of 8 handles resizes a possibly-rotated
box while keeping the opposite anchor fixed in world space. The math works in
the shape's local frame (
src/geometry/transform.ts) and is unit-tested, including the rotated case and group scaling. - Local-scoped undo in a shared document. A naive
UndoManagerwould let you undo a collaborator's edits. Scoping it to a local transaction origin (trackedOrigins) and breaking the capture group at each gesture boundary gives clean, per-user, per-action undo. - 60fps under load. Achieved by layering the canvases and keeping the base layer idle unless the scene or camera actually changes. Measured at ~60fps while continuously panning a 500-shape board.
- Rendering in a throttled tab. The loop guarantees a first paint and degrades
to
setTimeoutwhendocument.hidden, so state changes still reach the canvas in backgrounded/embedded contexts. - Snapping with guides. Moving a selection snaps its edges/centers to nearby shapes within a screen-constant threshold, and draws full-length alignment guides — the geometry is pure and unit-tested.
React 18 · TypeScript (strict, no any) · Vite · custom Canvas 2D · Zustand ·
Yjs + y-websocket + y-indexeddb · Tailwind CSS · lucide-react · Vitest · Playwright.
- Vitest covers the correctness-critical pure geometry — coordinate conversion & zoom-to-cursor, resize/rotate/group transforms, and snapping — written test-first.
- Playwright drives real flows, including a two-context multiplayer test that proves edits and cursors sync between tabs, and a presence test asserting peers see each other's cursors, selections and avatars.
Accounts/auth, a backend database, comments, a component library, image uploads, and plugins are intentionally out of scope. Depth over breadth.

