Introduction
Design tools make collaboration hard in a specific way. Every action is about positioning, objects moving, resizing, stacking on top of each other, and when two people do that at the same time on the same canvas, one person's change will quietly overwrite the other's if nothing is managing the conflict.
We built PixFrame, a Canva-like design editor, to see what that looks like in practice. This article explains how the collaboration layer is built, including CRDT sync, live cursors, presence, and comments. We used Velt for the collaboration layer and the Velt Plugin to set it up quickly.
Here is the final result.
This article walks through how the editor is built, how Velt connects to a Fabric.js canvas, and what the Plugin workflow actually looked like.
The Stack
- React + Vite: app framework and dev server
- **Fabric.js:** canvas engine; handles the object model, transforms, serialization, and everything that makes a design editor feel like a design editor
- Zustand: global editor state: layers, undo/redo history, active panel, theme, export settings
- Velt: the entire collaboration layer: CRDT sync, live cursors, presence, comments, and notifications
- Tailwind CSS: styling
- TypeScript: type safety across the canvas and collaboration logic
For the Velt side, I used the Velt Plugin throughout the build. It bundles the MCP server, Agent Skills, and a velt-expert agent persona in one install, so instead of reading through setup docs and manually wiring providers, the agent handled it. Collaboration features that would have required a day or two of SDK work were reduced to a few slash commands.
Get It Running
Clone the repo and drop your Velt API key in a .env file.
git clone https://github.com/Studio1HQ/Canva-style-design-editor-UI
cd Canva-style-design-editor-UI
npm install
VITE_VELT_API_KEY=your_velt_api_key
Run npm run dev and open http://localhost:5173. Get your API key from the Velt dashboard. Now, the rest of the article walks through how the collaboration layer is wired into the editor via the Velt plugin and related components.
How We Added Collaboration
There are two ways to set up Velt, depending on your editor.
On Cursor or Claude Code: install the Velt Plugin. It ships with everything already bundled: MCP server, Agent Skills, rules, and a velt-expert agent persona. Slash commands like /install-velt, /add-comments, and /add-presence show up directly in the editor. One install, nothing else to configure.
On any other editor, the Plugin is not available, so you set up the two pieces it bundles separately. Add the Velt MCP server to feed live Velt API references into your agent's context, and install Agent Skills so the agent knows how to generate correct Velt code for setup, comments, CRDT, and notifications:
npx skills add velt-js/agent-skills
npx -y @velt-js/mcp-installer
Restart the editor, then open your agent chat and type install velt. The agent asks about your project structure, API key location, and which features you need, then generates a plan, shows you a diff, and applies it on approval.
We were on Cursor, so we used the Plugin and ran /install-velt directly in the editor.
Wiring Velt Into the App
Running /install-velt is what generated everything below. The agent scanned the project, detected the component structure, and placed VeltProvider at the root, outside FabricProvider , so the collaboration layer is available across the entire tree before the canvas mounts:
<VeltProvider apiKey={apiKey}>
<FabricProvider>
<AppContent currentUser={currentUser} onSwitchUser={handleSwitchUser} />
</FabricProvider>
</VeltProvider>
Inside AppContent, the agent set up user identification and document context in sequence:
await client.identify(currentUser);
await client.setDocument("pixframe-collaborative-canvas", {
documentName: "Pixframe Design",
});
identify has to be completed before setDocument runs. identify runs first because Velt needs to know who the user is before it can tie them to a document. If setDocument runs before identify completes, Velt cannot associate the session with the correct user, and collaboration does not attach properly, even if the canvas loads fine.
The demo runs two hardcoded users with a switcher in the top bar. For real auth, replace currentUser with whatever your auth provider returns. Nothing else changes.
How the Canvas Stays in Sync
With the provider set up and users identified, the next question is how canvas changes from one user to reach everyone else. This section covers the CRDT layer that makes that possible, the hook that connects it to Fabric, and the guard that keeps the whole thing from looping back on itself.
What CRDT Means Here
When two users edit the same canvas simultaneously, a mechanism must determine the final state. Without a conflict resolution strategy, one edit silently overwrites the other, and neither user knows it happened.
Velt's CRDT layer handles this internally using Yjs. Every change is recorded as an operation, not a full replacement, so edits from different users merge without one cancelling the other. For libraries such as ReactFlow and SlateJS, Velt offers prebuilt CRDT integrations. Fabric.js is not one of them, so we used Velt's CRDT Core, which gives direct access to the underlying Yjs store without being tied to any specific library.
The Hook That Connects Fabric to Velt
For Velt CRDT integration, we ran /add-crdt in Cursor. The agent detected we were using Fabric, reached for Velt's CRDT Core since there was no prebuilt integration for it, and generated src/hooks/useCollaborativeEditor.ts as the bridge between the canvas and the CRDT store.
The hook sets up a single shared store for the entire canvas state:
const { value, update } = useVeltCrdtStore<string>({
id: "pixframe-canvas-state",
type: "text",
initialValue: "",
enablePresence: false,
});
type: "text" gives a full replacement store backed by Y.Text. Every snapshot, the serialized Fabric JSON, the layer list, and the canvas dimensions, get stringified and pushed into it as one blob.
Pushing local changes
Every canvas mutation calls pushCanvasState, which is debounced at 150ms. This means rapid, continuous actions like dragging an object only push one update instead of one per frame:
const pushCanvasState = useCallback((canvasJson: string) => {
if (isRemoteUpdate.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const state = useEditorStore.getState();
const snapshot = JSON.stringify({
json: canvasJson,
layers: JSON.stringify(state.layers),
canvasSize: state.canvasSize,
});
lastPushedRef.current = snapshot;
update(snapshot);
}, 150);
}, [update, isRemoteUpdate]);
For discrete actions like delete or layer visibility toggle, there is a separate pushCanvasStateImmediate that skips the debounce entirely and cancels any pending debounced push, because those changes need to land in the CRDT store right away.
Both functions share one thing in common: the first line of each checks isRemoteUpdate.current before doing anything else.
Why isRemoteUpdate exists
When the CRDT store delivers a remote snapshot, the hook calls canvas.loadFromJSON to apply it. That triggers Fabric's own change events, the same ones that call pushCanvasState. Without any blocking, the hook would push the remote state back out as a local edit, triggering another snapshot on every peer and firing events again. The loop runs until something breaks.
isRemoteUpdate is a ref that gets set to true before any remote snapshot is applied. Every push function checks it at the top and returns early if it is true:
const pushCanvasState = useCallback((canvasJson: string) => {
if (isRemoteUpdate.current) return;
// rest of push logic
}, [update, isRemoteUpdate]);
The flag clears two animation frames after loadFromJSON completes, not immediately. The setLayers call inside applySnapshot triggers a React effect in CanvasArea on the next render. If the flag cleared too early, that effect would see it as false and try to purge objects that were just loaded from the snapshot. The two-frame delay keeps it alive long enough for that effect to bail out correctly.
The Collaboration Layer
With the CRDT sync in place, the next step was adding the visible collaboration features: cursors, comments, presence, and notifications. Each one came from a single slash command in Cursor using the Velt Plugin, /add-cursors, /add-comments, /add-presence, /add-notifications. No manual component wiring, no reading through setup docs for each feature individually.
Live Cursors
VeltCursor is inside CanvasArea, wrapped in an absolutely positioned overlay:
<div className="absolute inset-0 pointer-events-none z-[150]">
<VeltCursor />
</div>
Placing it here means the cursor tracks within the canvas boundary. If it were at the root, cursor positions would be relative to the full page layout, including the sidebars and toolbar, which would make them inaccurate for anyone working on the canvas.
Comments
VeltComments and VeltCommentsSidebar sit at the app root in App.tsx so comment pins can appear anywhere across the full editor surface, not just over the canvas.
Both have shadowDom={false}:
<VeltComments shadowDom={false} />
<VeltCommentsSidebar shadowDom={false} />
Velt components use Shadow DOM by default, which isolates them from your global CSS. Turning it off is what makes theming work. Once it is off, generate your token set from Velt's theme playground and paste it into your global CSS:
body {
--velt-light-mode-accent: #6366f1;
--velt-light-mode-background-0: #ffffff;
--velt-dark-mode-accent: #6366f1;
--velt-dark-mode-background-0: #111114;
}
Dark mode stays in sync by calling client.setDarkMode(theme === "dark") whenever the app theme changes. That single call covers all Velt components.
Presence and Notifications
VeltPresence and VeltNotificationsTool both sit in TopBar, inside VeltProvider:
<VeltPresence />
<VeltNotificationsTool />
VeltPresence renders the avatar stack for everyone currently on the document. VeltNotificationsTool shows a bell with a badge that updates on new comments, replies, and mentions.
The CRDT hook handles pushing and applying snapshots, but with Fabric.js, a specific issue makes the wiring less straightforward than it appears.
The Fabric–Velt Bridge
The collaboration layer in the previous section assumes that the hook can distinguish between a local canvas change and a remote change being applied. With Fabric, that assumption does not hold by default.
Fabric fires object:modified, object:added, and object:removed for every canvas change, whether it came from the local user or from loadFromJSON applying a remote snapshot. There is no built-in way to distinguish them.
Without a guard: a remote snapshot arrives, the hook calls loadFromJSON, Fabric fires object:added for each restored object, those events call pushCanvasState, which pushes the remote state back into the CRDT store as a local edit, which triggers another snapshot on every peer. The loop runs until something breaks.
isRemoteUpdate is the guard. It is a ref, not state, because a re-render mid-snapshot would cause more problems than it solves. It flips to true before loadFromJSON runs, and every push function checks it first:
const pushCanvasState = useCallback((canvasJson: string) => {
if (isRemoteUpdate.current) return;
// rest of push logic
}, [update, isRemoteUpdate]);
It clears two animation frames after loadFromJSON completes, not immediately. The setLayers call inside applySnapshot triggers a React effect in CanvasArea on the next render. Clearing the flag too early causes the effect to be treated as false and to purge objects that were just loaded. The two-frame delay keeps it alive long enough.
The Bug That Broke Collaboration on Remote Peers
The echo loop was resolved, but a second issue only appeared when a second user joined.
After receiving and applying a snapshot, clicking any canvas object selected nothing. The layers panel went blank. Delete, visibility toggle, crop, all dead. Everything looked fine visually, but the editor was frozen for anyone who joined second.
The root cause was a Fabric 7 quirk. canvas.toJSON(['data']) ignores the propertiesToInclude argument and calls this.toObject() with no arguments internally. The custom data field, which holds each object's id and links canvas objects to Zustand layers, was never included in any snapshot. Every object in every CRDT snapshot had data: undefined.
When the remote peer loaded that snapshot, no object had a data.id. Clicking them set selectedLayerId to null. Every panel check found nothing.
The fix was one line:
// Before — data.id silently dropped
const json = JSON.stringify(canvas.toJSON(['data']));
// After — data.id correctly included
const json = JSON.stringify(canvas.toObject(['data']));
canvas.toObject(['data']) respects propertiesToInclude. canvas.toJSON does not, despite the identical signature. This only surfaces with a second user because the originating peer keeps object references in memory and never depends on the serialized data.id.
The full pattern: push on local mutation, apply on remote value, guard with a ref, and serialize with toObject not toJSON.
What's Next
The full source is on GitHub. Clone it, run it, and the collaboration layer is live from the first load.
A few things worth building on top of this:
- Multi-page support: each page as a separate Velt document, with presence scoped per page
- PDF export: serialize the canvas per page and pipe it through a headless renderer
- Template library: save any canvas state as a named template and load it into a fresh document
The CRDT layer is already there. Most of these are additive, new UI and a document structure change, nothing that touches the sync logic.




Top comments (0)