DEV Community

Cover image for I Built a Canva-Like Editor With FabricJS and SvelteKit.
Suyash Thakur
Suyash Thakur

Posted on

I Built a Canva-Like Editor With FabricJS and SvelteKit.

I Built a Canva-Like Editor With FabricJS and SvelteKit. Here's What the Tutorials Don't Tell You.

Every FabricJS tutorial shows you how to add a rectangle to a canvas. Cool. Now build undo/redo. Now add layers. Now make multi-select work without breaking your layer order. Now serialize custom properties that FabricJS silently throws away. Now make it responsive without destroying your export resolution.

That's the gap. The "hello world" is easy. The actual editor is months of "why is this broken" at 1am.

I'm building Pictify — a programmable image engine where you design templates visually and render them via API. Think Canva's editor, but the output isn't a PNG you download. It's an API endpoint that generates images from data. It's launching soon, and the editor is the piece I'm most proud of (and most scarred by).

FabricJS v6. SvelteKit. XState. Here's everything I learned the hard way.


1. FabricJS + SvelteKit: The SSR Trap

First day of the project. I import FabricJS. SvelteKit immediately crashes because FabricJS touches the DOM on import and SvelteKit renders on the server. document is not defined. Classic.

The fix: dynamic import inside onMount.

import { onMount } from 'svelte';

let fabricCanvas;

onMount(async () => {
  const { Canvas: FabricCanvas } = await import('fabric');

  const canvasElement = document.createElement('canvas');
  canvasContainer.appendChild(canvasElement);

  fabricCanvas = new FabricCanvas(canvasElement, {
    width: 1080,
    height: 1080,
    backgroundColor: '#ffffff',
    preserveObjectStacking: true,
    selectionColor: 'rgba(59, 130, 246, 0.05)',
    selectionBorderColor: '#3b82f6',
    cornerSize: 8,
    cornerStyle: 'circle',
    transparentCorners: false
  });
});
Enter fullscreen mode Exit fullscreen mode

Two things to note:

preserveObjectStacking: true — Without this, FabricJS reorders objects when you select them (selected objects jump to the top). That destroys your layer order. Always enable this.

Create the canvas element in JS, don't put it in the template. If you put <canvas bind:this={el}> in your Svelte template, you'll fight lifecycle timing issues. Creating it in onMount guarantees the DOM is ready.


2. Custom Properties Vanish on Serialization

This one cost me three days and most of my sanity.

Pictify templates aren't just shapes on a canvas. Objects carry variable bindings, conditional logic, chart configurations, loop definitions. All custom properties. And FabricJS's toJSON() silently drops every single one of them.

// You add a custom property
rect.isVariable = true;
rect.variableBindings = [{ variableName: 'title', property: 'text' }];

// You serialize
const json = canvas.toJSON();
// json.objects[0].isVariable → undefined
// Gone.
Enter fullscreen mode Exit fullscreen mode

The fix: pass an array of custom property names to toJSON().

const json = canvas.toJSON([
  'id',
  'isVariable', 'variableBindings',
  'isChart', 'chartType', 'chartData', 'chartConfig',
  'isTable', 'tableHeaders', 'tableRows', 'tableData',
  'isQRCode', 'qrData', 'qrConfig',
  'showWhen', 'hideWhen',
  'loopVariable', 'loopDirection', 'loopSpacing'
]);
Enter fullscreen mode Exit fullscreen mode

Every custom property you ever want to survive a save/load cycle needs to be in that array. Miss one and you'll get a bug that only shows up after the user saves and reloads their template. The kind of bug where everything works perfectly in development because you never reload, and then your first real user loses all their variable bindings. Ask me how I know.

I keep this list in a constant and use it everywhere — toJSON(), undo/redo state snapshots, page switching, export. When I add a new custom property to objects, step one is adding it to this array. Before writing any other code. I have been burned enough.


3. The Event Loop From Hell: Why You Need Guard Flags

This is where FabricJS goes from "this is a great library" to "I am in a toxic relationship with this library."

FabricJS fires events in cascades. And if you're not careful, those cascades create infinite loops.

Here's the scenario that made me question my career choices: You listen to object:modified to save undo history. User moves a rectangle. Event fires, you save state. Great.

Now the user hits undo. You load the previous state with loadFromJSON(). FabricJS loads the objects... and fires object:added for each one. Your history listener sees object:added, saves state. Now your undo stack has a new entry that's identical to the one you just loaded. Hit undo again — nothing happens. Your history is corrupted.

The solution is guard flags. Not pretty. Not elegant. But I dare you to find a canvas editor that doesn't have them:

let isPerformingUndoRedo = false;
let isLoadingCanvas = false;
let isPageSwitching = false;

function saveState() {
  if (!fabricCanvas) return;
  if (isPerformingUndoRedo) return;
  if (isLoadingCanvas) return;
  if ($isBatching) return;
  if (isPageSwitching) return;

  historyStack = historyStack.slice(0, historyIndex + 1);
  const state = fabricCanvas.toJSON(CUSTOM_PROPERTIES);
  historyStack.push(state);

  if (historyStack.length > MAX_HISTORY) {
    historyStack.shift();
  } else {
    historyIndex++;
  }
}

function undo() {
  if (historyIndex <= 0) return;

  isPerformingUndoRedo = true;
  historyIndex--;
  const state = historyStack[historyIndex];

  fabricCanvas.loadFromJSON(state).then(() => {
    fabricCanvas.renderAll();
    setTimeout(() => {
      isPerformingUndoRedo = false;
    }, 50);
  });
}
Enter fullscreen mode Exit fullscreen mode

That setTimeout of 50ms at the end? I know what it looks like. I know. But FabricJS fires events asynchronously after loadFromJSON resolves. Without the delay, you clear the flag before the last object:added event fires, and the guard fails. I tried 10ms. Broke on slow machines. 50ms is the magic number. Don't ask me why. I don't know. It works.

Here's every event I listen to and why:

// Selection tracking — sync with properties panel
fabricCanvas.on('selection:created', handleSelection);
fabricCanvas.on('selection:updated', handleSelection);
fabricCanvas.on('selection:cleared', handleSelectionCleared);

// Save to undo history when user finishes a transform
// (fires ONCE after drag/resize ends, not during)
fabricCanvas.on('object:modified', (e) => {
  if (isPerformingUndoRedo || isLoadingCanvas) return;
  saveState();
});

// Track additions/removals for history
fabricCanvas.on('object:added', (e) => {
  if (e.target?.guideline) return;  // Skip alignment guides
  if (e.target?.selectable === false) return;  // Skip helper objects
  if (isDrawing || isPerformingUndoRedo || isLoadingCanvas) return;
  saveState();
});

fabricCanvas.on('object:removed', (e) => {
  if (isDrawing || isPerformingUndoRedo || isLoadingCanvas) return;
  saveState();
});

// Free drawing needs special handling —
// don't save on each stroke, save when path is complete
fabricCanvas.on('mouse:down', () => {
  if (fabricCanvas.isDrawingMode) isDrawing = true;
});

fabricCanvas.on('path:created', () => {
  isDrawing = false;
  saveState();
});
Enter fullscreen mode Exit fullscreen mode

Key insight: object:modified fires once when a transform ends. object:added fires for every object, including during loadFromJSON. You need different guards for different events.


4. Undo/Redo That Doesn't Corrupt Itself

Users don't appreciate undo/redo. Until it's broken. Then it's the only thing they notice.

The basic approach — snapshot the entire canvas as JSON — works fine. Until the edge cases show up. And they always show up.

The Branch Problem

When a user undoes 3 steps and then makes a new change, you need to discard the "future" states:

historyStack = historyStack.slice(0, historyIndex + 1);
historyStack.push(newState);
historyIndex++;
Enter fullscreen mode Exit fullscreen mode

If you forget the slice, the user can redo into a state that has nothing to do with their current canvas.

Batching: AI Makes 15 Changes, User Wants 1 Undo

Pictify has an AI copilot that modifies the canvas programmatically. "Make the background darker and add a headline" turns into 15 canvas operations. Without batching, that's 15 undo entries. The user hits Cmd+Z expecting to reverse "what the AI just did" and instead the headline loses 2px of font size. They hit undo 14 more times before they're back to where they started. Horrible UX.

// In the store
export const batchState = writable({
  isActive: false,
  description: null,
  source: 'user' // 'user' | 'copilot' | 'system'
});

export const historyActions = {
  startBatch: (description, source = 'user') => {
    batchState.set({ isActive: true, description, source });
  },
  endBatch: () => {
    batchState.set({ isActive: false, description: null, source: 'user' });
  }
};
Enter fullscreen mode Exit fullscreen mode
// In the canvas component — subscribe to batch state
batchState.subscribe(($batch) => {
  // When batch transitions from active → inactive, save one state
  if (!$batch.isActive && fabricCanvas) {
    setTimeout(() => saveState(), 10);
  }
});
Enter fullscreen mode Exit fullscreen mode

Usage:

historyActions.startBatch('Copilot changes', 'copilot');
canvas.add(rect1);
canvas.add(rect2);
canvas.add(text1);
// ... 12 more operations
historyActions.endBatch(); // One undo entry for all of it
Enter fullscreen mode Exit fullscreen mode

The saveState() function checks $isBatching and skips if true. When the batch ends, a single save captures the final state.

Per-Page History

If your editor supports multi-page templates (I use this for PDFs), each page needs its own history stack. Otherwise undo on page 3 reverts a change you made on page 1.

let pageHistories = new Map(); // pageIndex → { stack, index }

function savePageHistory(pageIndex) {
  pageHistories.set(pageIndex, {
    stack: [...historyStack],
    index: historyIndex
  });
}

function restorePageHistory(pageIndex) {
  const saved = pageHistories.get(pageIndex);
  if (saved) {
    historyStack = [...saved.stack];
    historyIndex = saved.index;
  } else {
    historyStack = [];
    historyIndex = -1;
    saveState(); // Save initial state for new page
  }
}
Enter fullscreen mode Exit fullscreen mode

5. Grouping and Ungrouping: Coordinate Hell

Grouping objects sounds simple. Right-click, "Group", done. I estimated two hours for this feature. It took two days.

When FabricJS creates a Group, it converts each object's position from canvas coordinates to group-local coordinates. The group's center becomes the origin, and all objects are offset relative to that.

When you ungroup, you need to reverse that transformation. FabricJS has a private method for this:

function ungroupSelected(canvas) {
  const group = canvas.getActiveObject();
  if (group?.type?.toLowerCase() !== 'group') return;

  // Don't ungroup protected elements
  if (group.isQRCode || group.isChart || group.isTable) return;

  const objects = group._objects.concat();

  // This private method restores canvas coordinates
  if (typeof group._restoreObjectsState === 'function') {
    group._restoreObjectsState();
  }

  canvas.remove(group);
  objects.forEach(obj => canvas.add(obj));

  // Re-select all ungrouped objects
  const selection = new ActiveSelection(objects, { canvas });
  canvas.setActiveObject(selection);
  canvas.requestRenderAll();
}
Enter fullscreen mode Exit fullscreen mode

_restoreObjectsState() is undocumented. It's a private FabricJS method that starts with an underscore, which is the JavaScript equivalent of a sign that says "you're on your own, pal." If it gets renamed in a future version, this breaks. But there's no public API for this. I checked. Twice. Asked in the FabricJS GitHub discussions. This is the way.

The same logic applies to double-click-to-ungroup:

fabricCanvas.on('mouse:dblclick', (e) => {
  if (e.target?.type === 'group') {
    const items = e.target._objects.concat();

    if (typeof e.target._restoreObjectsState === 'function') {
      e.target._restoreObjectsState();
    }

    fabricCanvas.remove(e.target);
    items.forEach(obj => fabricCanvas.add(obj));

    const selection = new ActiveSelection(items, { canvas: fabricCanvas });
    fabricCanvas.setActiveObject(selection);
    fabricCanvas.requestRenderAll();
    saveState();
  }
});
Enter fullscreen mode Exit fullscreen mode

6. The Layers Panel: Everything Is Backwards

This one is a rite of passage. Every person who builds a layers panel with FabricJS goes through the same 45 minutes of confusion.

FabricJS stores objects in draw order: index 0 is the bottom-most layer, index N is the top. But every design tool shows layers top-to-bottom — the top layer is first in the list.

This means your UI displays the array reversed. And when you implement drag-to-reorder, "dragging up" in the UI means "moving to a higher index" in FabricJS.

function updateLayers() {
  const objects = canvas.getObjects();
  let result = [];

  // Iterate in REVERSE — top of canvas = top of layer list
  for (let i = objects.length - 1; i >= 0; i--) {
    const obj = objects[i];
    if (obj.guideline || obj.grid) continue; // Skip helper objects

    result.push({
      id: obj.id || `layer_${i}`,
      name: obj.name || `${obj.type} ${i + 1}`,
      type: obj.type,
      visible: obj.visible !== false,
      locked: obj.selectable === false,
      object: obj,
      index: i,
      isGroup: obj.type === 'group' && obj._objects?.length > 0
    });
  }

  layers = result;
}
Enter fullscreen mode Exit fullscreen mode

The reorder logic inverts the "insert before/after" direction:

function reorderLayer(draggedObj, targetObj, insertAfter) {
  const objects = canvas.getObjects();
  const draggedIndex = objects.indexOf(draggedObj);
  const targetIndex = objects.indexOf(targetObj);

  // UI is reversed, so invert the insertion direction
  const effectiveInsertAfter = !insertAfter;

  let newPosition = targetIndex;
  if (draggedIndex < targetIndex) {
    newPosition = effectiveInsertAfter ? targetIndex : targetIndex - 1;
  } else {
    newPosition = effectiveInsertAfter ? targetIndex + 1 : targetIndex;
  }

  newPosition = Math.max(0, Math.min(newPosition, objects.length - 1));
  draggedObj.moveTo(newPosition);
  canvas.renderAll();
}
Enter fullscreen mode Exit fullscreen mode

That !insertAfter inversion is one line of code. It took me 45 minutes of dragging layers up and watching them go down to figure out I needed it. Simple in hindsight. Maddening in the moment.


7. Responsive Canvas: Don't Use FabricJS Zoom

My first attempt at responsive scaling used FabricJS's built-in setZoom(). Seemed logical. It was a disaster. Zoom affects everything — object coordinates, mouse hit detection, exported image dimensions. If you zoom to 50% to fit the canvas on screen, your exported images come out at half resolution. For Pictify, where the entire point is generating pixel-perfect images via API, that's a dealbreaker.

Instead, use CSS transform: scale() on the canvas container:

function updateScale() {
  const wrapper = canvasContainer.parentElement.parentElement;
  const availableWidth = wrapper.clientWidth - 64;
  const availableHeight = wrapper.clientHeight - 160;

  const scaleX = availableWidth / fabricCanvas.width;
  const scaleY = availableHeight / fabricCanvas.height;

  // Fit entirely, with breathing room
  scale = Math.min(scaleX, scaleY, 1) * 0.95;
}
Enter fullscreen mode Exit fullscreen mode
<div style="transform: scale({scale});">
  <div bind:this={canvasContainer}></div>
</div>
Enter fullscreen mode Exit fullscreen mode

CSS scale doesn't affect FabricJS internals. Mouse coordinates still work correctly. Exported images are full resolution. And ResizeObserver keeps it responsive:

onMount(() => {
  const resizeObserver = new ResizeObserver(() => updateScale());
  resizeObserver.observe(canvasContainer.parentElement.parentElement);
  return () => resizeObserver.disconnect();
});
Enter fullscreen mode Exit fullscreen mode

8. State Management: XState as a Thin Router

I know what you're thinking. "XState for a canvas editor? Isn't that overkill?"

Probably. I use it for the editor state, but not in the way any XState tutorial would suggest. There's one state: ready. No complex nested states. No guards. No parallel regions. It's a state machine that never transitions.

const editorMachine = createMachine({
  id: 'editor',
  initial: 'ready',
  context: {
    canvas: null,
    selectedComponent: null,
    activeSidebarTab: 'elements',
    activeRightSidebarTab: 'properties',
    canvasZoom: 100
  },
  states: {
    ready: {
      on: {
        SET_CANVAS: { actions: 'setCanvas' },
        SELECT_COMPONENT: { actions: 'selectComponent' },
        CLEAR_SELECTION: { actions: 'clearSelection' },
        SET_CANVAS_ZOOM: { actions: 'setCanvasZoom' },
        TOGGLE_LEFT_SIDEBAR_TAB: { actions: 'toggleLeftSidebar' },
        TOGGLE_RIGHT_SIDEBAR_TAB: { actions: 'toggleRightSidebar' }
      }
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

So why not plain Svelte stores? Honestly, it would work. But I'm a solo founder building this thing alone. When I come back to the editor code at 2am after a week of working on the API layer, I can look at the machine definition and immediately see every possible thing that can happen. "Which events update selectedComponent?" One file. One glance. That's worth the dependency for me.

The bridge to Svelte's reactivity:

const selectContext = (selector) =>
  derived(editorState, ($state) =>
    $state ? selector($state.context) : null
  );

export const editor = selectContext((ctx) => ctx.canvas);
export const selectedComponent = selectContext((ctx) => ctx.selectedComponent);
Enter fullscreen mode Exit fullscreen mode

Components subscribe to exactly what they need. The properties panel watches selectedComponent. The canvas component watches editor. No prop drilling.


9. Properties Panel: The 200KB Monster

Every canvas editor has that one component. The one file that keeps growing. The one you're slightly afraid to open. For me, it's the properties panel. ~200KB. Handles 8+ different object types. Reactively updates on every selection change. It's the cockpit of the whole editor.

The core pattern:

$: if ($selectedComponent) {
  updateLocalState();
}

function updateLocalState() {
  const obj = $selectedComponent;
  const type = obj.type?.toLowerCase();

  // Common properties
  styles = {
    fill: obj.fill,
    stroke: obj.stroke,
    strokeWidth: obj.strokeWidth,
    width: Math.round(obj.getScaledWidth()),
    height: Math.round(obj.getScaledHeight()),
  };

  // Type-specific properties
  if (type === 'i-text' || type === 'text') {
    content = obj.text;
    // Load font, alignment, spacing...
  }

  if (type === 'rect') {
    cornerRadii = obj.isRoundedRect
      ? obj.cornerRadii
      : { tl: obj.rx || 0, tr: obj.rx || 0, br: obj.rx || 0, bl: obj.rx || 0 };
  }

  if (type === 'image') {
    loadImageFilters();
    currentClipShape = getImageClipShape();
  }

  // Custom element types
  if (obj.isChart) loadChartConfig();
  if (obj.isTable) loadTableConfig();
  if (obj.isQRCode) loadQRConfig();
}
Enter fullscreen mode Exit fullscreen mode

The gotcha: FabricJS's fill can be a string ('#ff0000') OR a Gradient object. If you bind it directly to a color picker, the color picker explodes when it receives an object. You need to handle both cases.

Also, getScaledWidth() vs width. FabricJS objects have a width property and a scaleX property. The actual rendered width is width * scaleX. Use getScaledWidth() in the UI, or your dimension inputs will show wrong numbers when the user scales an object.


10. Complex Objects: QR Codes, Charts, Tables

This is where Pictify gets interesting and where FabricJS gets creative.

Pictify templates can contain QR codes, bar charts, data tables — things FabricJS has no concept of. These are all FabricJS Groups with custom properties glued on top. A QR code is literally a group of hundreds of tiny rectangles (or circles, depending on the style). A bar chart is a group of bars, labels, grid lines. FabricJS doesn't know they're special. My code does.

function createQRCode(config) {
  const { modules: matrix, size: moduleCount } = generateQRMatrix(data);
  const objects = [];

  // Background
  objects.push(new Rect({ width, height, fill: bgColor }));

  // Modules
  for (let row = 0; row < moduleCount; row++) {
    for (let col = 0; col < moduleCount; col++) {
      if (matrix[row * moduleCount + col] === 1) {
        objects.push(new Rect({
          left: offset + col * moduleSize,
          top: offset + row * moduleSize,
          width: moduleSize,
          height: moduleSize,
          fill: fgColor
        }));
      }
    }
  }

  return new Group(objects, {
    id: `qr_${Date.now()}`,
    isQRCode: true,       // Custom flag
    qrData: data,          // Preserved via toJSON([...])
    qrConfig: { fgColor, bgColor, patternStyle }
  });
}
Enter fullscreen mode Exit fullscreen mode

The isQRCode flag does double duty. It prevents ungrouping (imagine a user accidentally exploding a QR code into 500 individual rectangles, then hitting Cmd+Z and praying) and tells the properties panel to show QR-specific controls instead of generic group controls.

And yes — these custom properties only survive serialization if you include them in the toJSON([...]) array from section 2. I learned that lesson once. I don't need to learn it again.


The Cheat Sheet

I'll save you some time. Here's everything from this post in a list you can bookmark:

  1. Always pass custom properties to toJSON([]) — or they vanish on save
  2. Use guard flags on every event listenerisPerformingUndoRedo, isLoadingCanvas, isBatching
  3. Don't use FabricJS zoom for responsive scaling — use CSS transform: scale()
  4. Enable preserveObjectStacking: true — or selections break your layer order
  5. Iterate layers in reverse — and invert insertion direction for reordering
  6. Use _restoreObjectsState() for ungrouping — no public API exists
  7. Batch undo entries for programmatic changes — or your AI copilot creates 50 undo steps
  8. Add setTimeout(fn, 50) after loadFromJSON() — FabricJS fires events asynchronously after the Promise resolves

Building a canvas editor with FabricJS is 20% "put shapes on a canvas" and 80% fighting the stuff on this list. The tutorials get you through the first 20%. Hopefully this post gets you through the rest without losing a week to each one like I did.


What I'm Building With All This

This editor is the front half of Pictify — a programmable image engine I'm launching soon. You design templates in this editor (with variable bindings, conditional logic, loops, charts, QR codes). Then you hit a REST API with JSON data and get rendered images back in ~200ms. Your marketing team designs the template. Your engineering team calls the API. Your AI agent generates 10,000 variations at 2am. Nobody opens a browser.

If that sounds useful to you — or if you're deep in the FabricJS trenches yourself and want to compare battle scars — find me on LinkedIn. I'm always down to talk canvas editors with people who understand the pain.

Drop a comment with the worst FabricJS bug you've encountered. I'll go first: mine was the serialization one. Three days. Three entire days.

Top comments (0)