Vue / Quasar / Nuxt Integration

Design on Web does not ship a dedicated Vue wrapper, but you can use the @design-on-web/vanilla package directly in any Vue 3 + Vite project (including Quasar, Nuxt, and Vitepress). The example below mirrors the structure of the React integration but uses the framework-agnostic API.

ℹ️
Why no @design-on-web/vue package?
The full UI shell (toolbars, side panels, layer panel, property panel, export dialog) is implemented in React. For Vue projects you build the UI yourself in Vue components and call the EditorAPI from @design-on-web/vanilla for canvas operations. The trade-off is more setup, but you get full control over the look-and-feel.

1. Installation

Install the vanilla package plus Fabric.js as a peer dependency:

Installbash
pnpm add @design-on-web/vanilla @design-on-web/core fabric

# Vite + Vue 3 starter
pnpm create vite my-editor --template vue-ts
cd my-editor
pnpm install

2. Vite Configuration

When consuming the SDK as a linked or workspace dependency (e.g. in a monorepo), exclude it from Vite's dep optimizer so source changes trigger HMR:

vite.config.tstypescript
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  optimizeDeps: {
    // Critical when consuming locally linked packages — Vite must
    // serve them directly so dist updates trigger HMR.
    exclude: [
      '@design-on-web/core',
      '@design-on-web/shared',
      '@design-on-web/vanilla',
    ],
  },
});
⚠️
pnpm linking gotcha
When pulling the SDK from a sibling folder via pnpm, use link:../path/to/package not file:../path/to/package. The file: protocol copies the package into node_modules/.pnpm, so rebuilding the SDK's dist/ output is not picked up. link: creates a real symlink and HMR works correctly.

3. Minimal Vue Component

A complete editor in a single Vue file. The canvas host is a plain<div> — Fabric.js manages the inner canvas element. Smart guides are enabled after initWorkspace so the canvas is properly mounted.

App.vuevue
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue';
import { createEditor } from '@design-on-web/vanilla';
import type { EditorAPI, EditorEngine } from '@design-on-web/core';

const canvasHost = ref<HTMLDivElement | null>(null);
const engine = shallowRef<EditorAPI | null>(null);

onMounted(() => {
  if (!canvasHost.value) return;

  const api = createEditor(canvasHost.value, {
    width: 1080,
    height: 1080,
    backgroundColor: '#ffffff',
    onReady: (editor) => {
      // Resize the workspace to fill the host THEN enable smart guides
      // (smart guides need contextTop, which exists once the canvas
      // is mounted in the DOM with valid dimensions).
      const eng = editor as unknown as EditorEngine;
      const cm = eng.getCanvasManager();
      const rect = canvasHost.value!.getBoundingClientRect();
      cm.initWorkspace(rect.width, rect.height, '#ffffff');
      eng.enableSmartGuides();

      // Seed sample content
      editor.addShape('rect', {
        left: 280, top: 320, width: 320, height: 200,
        fill: '#8b3dff', rx: 12, ry: 12,
      });
    },
  });

  engine.value = api;
});

onBeforeUnmount(() => {
  if (engine.value) {
    (engine.value as unknown as EditorEngine).destroy();
    engine.value = null;
  }
});

function addCircle() {
  engine.value?.addShape('circle', {
    left: 400, top: 400, radius: 80, fill: '#ec4899',
  });
}

async function downloadPng() {
  if (!engine.value) return;
  const blob = await engine.value.exportAs('png');
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'design.png';
  a.click();
  URL.revokeObjectURL(url);
}
</script>

<template>
  <div style="display: flex; flex-direction: column; height: 100vh">
    <header style="padding: 12px; background: #1a1a1a; color: #fff">
      <button @click="addCircle">+ Circle</button>
      <button @click="downloadPng">Download PNG</button>
    </header>
    <main style="flex: 1; position: relative">
      <div ref="canvasHost" style="position: absolute; inset: 0"></div>
    </main>
  </div>
</template>

4. Building a Layer Panel in Vue

Subscribe to canvas events to keep a Vue ref in sync with the object list. The pattern below works for any custom panel (properties, history, pages, etc.):

LayerPanel.vuevue
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { createEditor } from '@design-on-web/vanilla';
import type { EditorAPI, EditorEngine } from '@design-on-web/core';

interface LayerInfo {
  id: string;
  name: string;
  type: string;
  visible: boolean;
  isActive: boolean;
}

const layers = ref<LayerInfo[]>([]);
const engine = ref<EditorAPI | null>(null);

function refreshLayers(api: EditorAPI) {
  const fabricCanvas = api.getCanvas() as {
    getObjects: () => unknown[];
    getActiveObject: () => unknown;
  };
  const activeObj = fabricCanvas.getActiveObject() as { id?: string } | null;

  layers.value = (fabricCanvas.getObjects() as Array<{
    id?: string;
    name?: string;
    type?: string;
    visible?: boolean;
    _isWorkspace?: boolean;
    _isGridLine?: boolean;
  }>)
    .filter((o) => !o._isWorkspace && !o._isGridLine && o.id)
    .map((o) => ({
      id: o.id!,
      name: o.name ?? o.type ?? 'Object',
      type: o.type ?? 'unknown',
      visible: o.visible !== false,
      isActive: o.id === activeObj?.id,
    }))
    .reverse();
}

onMounted(() => {
  // ... createEditor setup ...

  // Subscribe to canvas events for live layer list
  const eng = engine.value as unknown as EditorEngine;
  const events = (eng as unknown as { eventBus: { on: Function } }).eventBus;
  const fabricCanvas = engine.value!.getCanvas() as {
    on: (e: string, cb: () => void) => void;
  };

  const onChange = () => refreshLayers(engine.value!);
  events.on('object:added', onChange);
  events.on('object:removed', onChange);
  events.on('object:modified', onChange);
  fabricCanvas.on('selection:created', onChange);
  fabricCanvas.on('selection:updated', onChange);
  fabricCanvas.on('selection:cleared', onChange);
});

function selectLayer(id: string) {
  engine.value?.selectObject(id);
}

function deleteLayer(id: string) {
  engine.value?.removeObject(id);
}
</script>

<template>
  <aside class="layer-panel">
    <h3>Layers ({{ layers.length }})</h3>
    <div
      v-for="layer in layers"
      :key="layer.id"
      :class="{ active: layer.isActive }"
      @click="selectLayer(layer.id)"
    >
      {{ layer.name }}
      <button @click.stop="deleteLayer(layer.id)">🗑</button>
    </div>
  </aside>
</template>

5. SSR Frameworks (Nuxt / Quasar)

Fabric.js requires document and window, so the editor must only run on the client. Wrap your editor component in a client-only boundary:

Page.vuevue
// Quasar / Nuxt — wrap in client-only component
// because Fabric.js needs DOM access.

// Quasar:
<template>
  <q-no-ssr>
    <DesignEditor />
  </q-no-ssr>
</template>

// Nuxt 3:
<template>
  <ClientOnly>
    <DesignEditor />
  </ClientOnly>
</template>

What you get from the EditorAPI

The createEditor return value is the same EditorAPI used by the React integration. From your Vue components you can call:

  • addShape(type, options), addText(text, options), addImage(src, options)
  • selectObject(id), removeObject(id), getSelectedObjects()
  • undo(), redo(), canUndo(), canRedo()
  • alignObjects(), distributeObjects(), groupSelected(), ungroupSelected()
  • exportAs(format, options) — returns a Blob for any of 8 formats
  • getCanvas() — escape hatch to the underlying Fabric.js Canvas for advanced operations

Reference Implementation

A complete Vue + Vite reference app with Canva-like UI (left rail, shape grid, layer panel, property editor, zoom controls, export dialog) lives at design-on-web-test-vue/ in the project sources. It uses ~600 lines of Vue + 400 lines of CSS to build a full editor on top of the vanilla API.