Master Fabric.js: Build Interactive Canvas with Zoom, Snap, and Guides

This article walks through setting up Fabric.js on a web page, creating a canvas, drawing rectangles, adding zoom and pan controls, implementing object snapping and alignment guides, and provides complete source code so readers can quickly build an interactive canvas application.

Code Mala Tang
Code Mala Tang
Code Mala Tang
Master Fabric.js: Build Interactive Canvas with Zoom, Snap, and Guides

1. Initialize Fabric Canvas Instance

To start, add an HTML5 <canvas> element to the page and create a fabric.Canvas instance in index.js with the option preserveObjectStacking: true so that object stacking order is retained.

<!DOCTYPE html>
<html>
<head>
  <title>Fabric - Canvas</title>
  <meta charset="UTF-8" />
</head>
<body>
  <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px;">
    <div style="display: flex; align-items: center; gap: 4px;">
      Panning: <button id="btn-panning">Start</button>
    </div>
    <div id="app" style="display: flex; align-items: center; justify-content: center;">
      <canvas id="c" width="600" height="600"></canvas>
    </div>
  </div>
  <script src="src/index.js"></script>
</body>
</html>

In index.js the canvas is instantiated as follows:

import { fabric } from "fabric";
const canvas = (window._canvas = new fabric.Canvas("c", { preserveObjectStacking: true }));

2. Draw Rectangle Objects

Event bindings are set up for mouse actions, and a helper createRect creates rectangles with optional fill color.

const bindEvents = () => {
  canvas.on('mouse:up', onMouseUp);
  canvas.on('mouse:down', onMouseDown);
  canvas.on('mouse:move', onMouseMove);
  canvas.on('mouse:wheel', onMouseWheel);
  canvas.on('zoom:changed', onZoomChanged);
  canvas.on('object:moving', onObjectMoving);
  canvas.on('selection:cleared', onSelectionCleared);
};

const createRect = (left, top, color, width, height) => {
  const options = {
    originX: "left",
    originY: "top",
    left,
    top,
    width,
    height,
  };
  if (color) {
    options["fill"] = color;
  }
  const rect = new fabric.Rect(options);
  rect.guides = {};
  rect.guidePoints = {};
  canvas.add(rect);
  canvas.renderAll();
};

const init = () => {
  bindEvents();
  createRect(200, 200, "rgba(0, 0, 255, 1)", getRandomNumber(100, 200), getRandomNumber(100, 200));
  createRect(Math.floor(Math.random() * canvas.width), Math.floor(Math.random() * canvas.height), "rgba(0, 255, 0, 1)", getRandomNumber(100, 200), getRandomNumber(100, 200));
  createRect(Math.floor(Math.random() * canvas.width), Math.floor(Math.random() * canvas.height), "rgba(255, 0, 0, 1)", getRandomNumber(100, 200), getRandomNumber(100, 200));
  window.addEventListener('wheel', preventBrowserZoom, { passive: false });
};

init();

3. Add Zoom Functionality

When the user holds Ctrl and scrolls the mouse wheel, the canvas zoom level changes. The onMouseWheel handler adjusts the zoom, clamps it between 0.3 and 30, and calls canvas.zoomToPoint. If only the wheel is used, the canvas is panned.

const onMouseWheel = (opt) => {
  if (opt.e.ctrlKey) {
    const delta = opt.e.deltaY;
    let zoom = canvas.getZoom();
    zoom *= 0.999 ** delta;
    if (zoom > 30) zoom = 30;
    if (zoom < 0.3) zoom = 0.3;
    canvas.zoomToPoint({ x: opt.e.offsetX, y: opt.e.offsetY }, zoom);
    opt.e.preventDefault();
    opt.e.stopPropagation();
  } else {
    const e = opt.e;
    const currentViewportTransform = canvas.viewportTransform;
    currentViewportTransform[4] -= e.deltaX;
    currentViewportTransform[5] -= e.deltaY;
    canvas.forEachObject((obj) => obj.setCoords());
    canvas.requestRenderAll();
    opt.e.preventDefault();
    opt.e.stopPropagation();
  }
};

4. Add Object Snap and Alignment Guides

When a rectangle is moved, the object:moving event triggers snapObject and drawGuides. snapObject finds the nearest vertical and horizontal edges of other objects and, if within a threshold, aligns the moving object. drawGuides draws temporary lines and small marks to visualise the alignment.

const onObjectMoving = (e) => {
  const obj = e.target;
  if (!(obj instanceof fabric.Rect)) return false;
  obj.set("left", Math.round(obj.left));
  obj.set("top", Math.round(obj.top));
  if (isPanning) return;
  snapObject(obj);
  drawGuides(obj);
};

const snapObject = (obj) => {
  let objVerticals = [obj.left, obj.left + Math.round(obj.getScaledWidth()), obj.left + Math.round(obj.getScaledWidth()) / 2.0];
  let objHorizontals = [obj.top, obj.top + Math.round(obj.getScaledHeight()), obj.top + Math.round(obj.getScaledHeight()) / 2.0];
  const targets = canvas.getObjects().filter(o => o.type !== "line" && o !== obj);
  let minAbsDiffVertical = 999;
  let minAbsDiffHorizontal = 999;
  let newPosVertical = 0;
  let newPosHorizontal = 0;
  for (const target of targets) {
    const targetVerticals = [target.left, target.left + Math.round(target.getScaledWidth()), target.left + Math.round(target.getScaledWidth()) / 2.0];
    const targetHorizontals = [target.top, target.top + Math.round(target.getScaledHeight()), target.top + Math.round(target.getScaledHeight()) / 2.0];
    targetVerticals.forEach((targetVertical) => {
      objVerticals.forEach((objVertical) => {
        if (Math.abs(targetVertical - objVertical) < minAbsDiffVertical) {
          minAbsDiffVertical = Math.abs(targetVertical - objVertical);
          newPosVertical = obj.left + targetVertical - objVertical;
        }
      });
    });
    targetHorizontals.forEach((targetHorizontal) => {
      objHorizontals.forEach((objHorizontal) => {
        if (Math.abs(targetHorizontal - objHorizontal) < minAbsDiffHorizontal) {
          minAbsDiffHorizontal = Math.abs(targetHorizontal - objHorizontal);
          newPosHorizontal = obj.top + targetHorizontal - objHorizontal;
        }
      });
    });
  }
  if (minAbsDiffHorizontal < 85) obj.set("top", newPosHorizontal);
  if (minAbsDiffVertical < 85) obj.set("left", newPosVertical);
  obj.setCoords();
};

const drawGuides = (obj) => {
  const targets = canvas.getObjects().filter(o => o.type !== "line" && o !== obj);
  const objLeft = obj.left;
  const objRight = obj.left + Math.round(obj.getScaledWidth());
  const objCenterX = (objLeft + objRight) / 2.0;
  const objTop = obj.top;
  const objBottom = obj.top + Math.round(obj.getScaledHeight());
  const objCenterY = (objTop + objBottom) / 2.0;
  const sides = ["left", "right", "centerX", "top", "bottom", "centerY"];
  sides.forEach((side) => {
    let value;
    let pointArray = [];
    switch (side) {
      case "top":
        value = objTop;
        pointArray = [objLeft, objRight, objCenterX];
        break;
      case "bottom":
        value = objBottom;
        pointArray = [objLeft, objRight, objCenterX];
        break;
      case "centerY":
        value = objCenterY;
        pointArray = [objLeft, objRight, objCenterX];
        break;
      case "left":
        value = objLeft;
        pointArray = [objTop, objBottom, objCenterY];
        break;
      case "right":
        value = objRight;
        pointArray = [objTop, objBottom, objCenterY];
        break;
      case "centerX":
        value = objCenterX;
        pointArray = [objTop, objBottom, objCenterY];
        break;
    }
    for (const target of targets) {
      const targetLeft = target.left;
      const targetRight = target.left + Math.round(target.getScaledWidth());
      const targetCenterX = (targetLeft + targetRight) / 2.0;
      const targetTop = target.top;
      const targetBottom = target.top + Math.round(target.getScaledHeight());
      const targetCenterY = (targetTop + targetBottom) / 2.0;
      switch (side) {
        case "top":
        case "bottom":
        case "centerY":
          if (inRange(value, targetTop) || inRange(value, targetBottom) || inRange(value, targetCenterY)) {
            pointArray.push(targetLeft, targetRight, targetCenterX);
          }
          break;
        case "left":
        case "right":
        case "centerX":
          if (inRange(value, targetLeft) || inRange(value, targetRight) || inRange(value, targetCenterX)) {
            pointArray.push(targetBottom, targetTop, targetCenterY);
          }
          break;
      }
    }
    if (obj.guides[side] instanceof fabric.Line) {
      canvas.remove(obj.guides[side]);
      delete obj.guides[side];
    }
    if (obj.guidePoints[side] != null) {
      obj.guidePoints[side].forEach((mark) => canvas.remove(mark));
      delete obj.guidePoints[side];
    }
    if (pointArray.length <= 3) return;
    const sortedPointArray = pointArray.sort((a, b) => a - b);
    const lineProps = { evented: true, stroke: "black", strokeWidth: 1, selectable: false, opacity: 1 };
    const MIN_SIZE = 5;
    const MIN_ZOOM = 0.3;
    const MAX_ZOOM = 30;
    const zoom = canvas.getZoom();
    const limitedZoomNumber = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom));
    const markSize = Math.max(MIN_SIZE, Math.round(MIN_SIZE / limitedZoomNumber));
    let ln;
    let marks = [];
    if (side === "top" || side === "bottom" || side === "centerY") {
      ln = new fabric.Line([sortedPointArray[0], value, sortedPointArray[sortedPointArray.length - 1], value], Object.assign(lineProps, { strokeWidth: lineProps.strokeWidth / zoom }));
      sortedPointArray.forEach((point) => {
        marks.push(new fabric.Line([point - markSize, value - markSize, point + markSize, value + markSize], Object.assign(lineProps, {})));
        marks.push(new fabric.Line([point - markSize, value + markSize, point + markSize, value - markSize], Object.assign(lineProps, {})));
      });
    } else {
      ln = new fabric.Line([value, sortedPointArray[0], value, sortedPointArray[sortedPointArray.length - 1]], Object.assign(lineProps, { strokeWidth: lineProps.strokeWidth / zoom }));
      sortedPointArray.forEach((point) => {
        marks.push(new fabric.Line([value - markSize, point - markSize, value + markSize, point + markSize], Object.assign(lineProps, {})));
        marks.push(new fabric.Line([value - markSize, point + markSize, value + markSize, point - markSize], Object.assign(lineProps, {})));
      });
    }
    obj.guides[side] = ln;
    obj.guidePoints[side] = marks;
    canvas.add(ln);
    marks.forEach((mark) => canvas.add(mark));
    canvas.renderAll();
  });
};

const inRange = (a, b) => a === b;

5. Implement Mouse‑Based Panning

A button toggles a boolean isPanning. When active and the left mouse button is pressed, mouse movements translate the canvas viewport.

const button = document.getElementById("btn-panning");
const handleButtonClick = () => {
  if (isPanning == true) {
    isPanning = false;
    button.innerHTML = "Start";
  } else {
    isPanning = true;
    button.innerHTML = "End";
  }
};

const onMouseDown = (opt) => {
  if (opt?.button == 1 && isPanning == true) {
    startPanning = true;
    lastPosX = opt?.e?.clientX;
    lastPosY = opt?.e?.clientY;
  }
};

const onMouseMove = (opt) => {
  if (startPanning) {
    const currentViewportTransform = canvas.viewportTransform;
    if (currentViewportTransform) {
      currentViewportTransform[4] += opt?.e?.clientX - lastPosX;
      currentViewportTransform[5] += opt?.e?.clientY - lastPosY;
      canvas.requestRenderAll();
      lastPosX = opt?.e?.clientX;
      lastPosY = opt?.e?.clientY;
    }
  }
};

6. Full Source Code

The complete HTML and JavaScript files are provided above; running them creates a canvas with three random rectangles, zoom‑and‑pan controls, and dynamic alignment guides.

Conclusion

This article shares practical experience using Fabric.js for canvas manipulation, covering initialization, drawing, zooming, snapping, guide rendering, and panning, with full source code to help developers get started quickly.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

frontendJavaScriptCanvasFabric.jsZoomSnap
Code Mala Tang
Written by

Code Mala Tang

Read source code together, write articles together, and enjoy spicy hot pot together.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.