Docs/Examples

Design canvas

A Figma-like editor — frames and layers on a zoomable canvas, smart guides, a rubber band, Alt to duplicate, and a layers panel that is the design's tree.

Live

Loading the demo…

<script setup lang="ts">
import { DnDProvider, type Announcements } from '@vue-dnd-kit/core';
import DesignCanvas from './DesignCanvas.vue';
import { KINDS } from './design';
import type { DragData } from './editor';
import type { ToolData } from './useLayerDrop';

// Screen readers hear names, not ids: a layer's, or the layer a tool adds. Rows of the
// layers panel keep the built-in texts (`undefined`), which say where they are in the tree.
const nameOf = (data: unknown) => {
  const { layer, tool } = (data ?? {}) as Partial<DragData & ToolData>;
  return layer?.name ?? (tool && `New ${KINDS[tool].name.toLowerCase()}`);
};
const placeOf = (over: { id: unknown; data: unknown }) =>
  over.id === 'canvas' ? 'the canvas' : `frame ${(over.data as { name: string }).name}`;

const announcements: Partial<Announcements> = {
  onDragStart: ({ active }, { count }) => {
    const name = nameOf(active.data);
    if (!name) return undefined;
    const others = count > 1 ? ` and ${count - 1} more` : '';
    return `Picked up ${name}${others}. Arrow keys move it, Enter drops it, Escape cancels.`;
  },
  onDragOver: ({ active, over }) => (nameOf(active.data) ? (over ? `Over ${placeOf(over)}` : 'Off the canvas') : undefined),
  onDragEnd: ({ active, over, dropped }) => {
    const name = nameOf(active.data);
    if (!name) return undefined;
    return dropped && over ? `${name} dropped on ${placeOf(over)}.` : `${name} went back.`;
  },
  onDragCancel: ({ active }) => (nameOf(active.data) ? `${nameOf(active.data)} went back.` : undefined),
};
</script>

<template>
  <!-- Space pans, as in any design tool, so Enter alone picks a layer up. Every dragged
       layer gets its clone at pickup, and the page doesn't scroll under a drag. -->
  <DnDProvider
    :keyboard="{ keys: { start: ['Enter'], drop: ['Enter'] }, step: 10 }"
    overlay-previews="all"
    :auto-scroll="false"
    :announcements="announcements"
    screen-reader-instructions="Press Enter to pick up a layer, the arrow keys to move it, Enter to drop it and Escape to cancel. Without a drag, the arrow keys nudge the selected layers."
  >
    <DesignCanvas />
  </DnDProvider>
</template>

What to look at

  • Shape.vue and Artboard.vue: every layer is a plain draggable, and a frame drags by its name (handle), its layers inside it. A press selects at once, as in Figma, so the provider's selection is what a drag takes: press one of several selected layers and they all move.
  • useLayerDrop.ts: the canvas and every frame are drop targets that work alike. A layer let go over a frame moves into it; over the canvas, out of it. Hold Alt and copies land instead, the layers staying where they were.
  • LayersPanel.vue: the panel is makeSortableTree on the design itself. The rows' order is the stacking order, and dropping a row into a frame moves the layer into that frame.
  • Canvas.vue: the smart guides are a modifier of the demo's own; makeSelectionArea draws the rubber band, and makeDragArea gives every drag that starts on the canvas its rules.
  • editor.ts, geometry.ts and design.ts: the design, its history, the view and the maths of snapping and aligning, with no drag and drop in them.

Drawn at the zoom

The canvas zooms without CSS scale(): every layer is laid out at its size times the zoom, in screen pixels. What the library measures is then what is on screen, so the drop targets, the rubber band and the clones in the overlay need nothing special. The world inside the canvas is moved by the pan alone, with translate().

Shape.vue
const style = computed(() => {
  const { zoom } = editor.view;
  const { x, y, width, height } = props.shape;
  return { left: `${x * zoom}px`, top: `${y * zoom}px`, width: `${width * zoom}px`, height: `${height * zoom}px` };
});

The wheel pans and zooms (Ctrl or a pinch), and so do Space-drag, the hand tool and the buttons in the corner: plain pointer and wheel events, as are the resize handles. Space being the hand, a layer picks up with Enter alone (the provider's keyboard.keys).

Moving layers between frames

A layer is a plain draggable with an overlay of its own, DragGroup.vue. It lays the dragged layers out as they lie: a DragPreview (the clone taken at pickup) of each, placed from the data of its layer, where it is drawn. The layers themselves step aside while their clones move, unless Alt is held.

Shape.vue
const { isDragging, selected } = makeDraggable(el, {
  id: () => props.shape.id,
  type: 'layer',
  data: () => editor.dragData(props.shape), // { layer, left, top }
  overlay: DragGroup,
});

On drop, position is where the grabbed layer's corner was let go inside the target, in screen pixels. Divided by the zoom, it says how far that layer went in the world, and every dragged layer moves as far, into the frame or onto the canvas:

useLayerDrop.ts
const corner = target ? editor.boxOf(target) : { x: -view.x / view.zoom, y: -view.y / view.zoom };
const toWorld = (x: number, y: number) => ({ x: corner.x + x / view.zoom, y: corner.y + y / view.zoom });

const from = editor.boxOf(grabbed); // the layer of `active.id`
const to = toWorld(position.x, position.y);
editor.move(active.ids, { x: to.x - from.x, y: to.y - from.y }, target, editor.altHeld.value);

A frame takes layers, not frames: a drag that carries a frame passes over the other frames (disabled, read live) onto the canvas. The targets have overDropAnimation: false, as the layers appear right where their clones were let go. Tools in the toolbar are plain draggables too: dropped on a frame, they add their layer into it, centred on the drop point; clicked, in the middle of the view.

Artboard.vue
makeDroppable(body, {
  id: () => props.frame.id,
  accept: 'layer',
  disabled: () => !!active.value?.items.some((item) => item.type.includes('frame')),
  overDropAnimation: false,
  onDrop: useLayerDrop(() => props.frame),
});

Smart guides

smartGuides is a modifier: it gets the drag's offset, works out where the dragged layers would be in the world, and pulls them onto the closest edge or centre of another layer within 6 screen pixels. The overlay, the target under it and the drop all follow. A monitor draws the pink lines and the distances from the drag's delta, the offset after the modifiers.

Canvas.vue
const smartGuides: Modifier = ({ transform, active }) => {
  const moving = editor.boundsOf(active.ids);
  if (!moving) return transform; // a tool from the toolbar
  const { zoom } = view;
  const box = offsetBox(moving, transform.x / zoom, transform.y / zoom);
  const targets = editor.snapTargets(active.ids);
  return {
    x: transform.x + snapBox(box, targets, 'x', SNAP / zoom) * zoom,
    y: transform.y + snapBox(box, targets, 'y', SNAP / zoom) * zoom,
  };
};

// Every drag that starts on the canvas, frames and layers alike, gets the guides.
makeDragArea(viewport, { modifiers: [smartGuides], disabled: panning });

The resize handles snap with the same lines and draw the same guides.

Selecting

A rubber band drawn on the empty canvas selects frames and loose layers; drawn inside a frame, the frame's layers. Both are makeSelectionArea: the innermost area takes the press, and owns the draggables inside it. Shift adds to the selection (strategy: 'toggle').

Artboard.vue
const { isSelecting, style: band } = makeSelectionArea(body, {
  strategy: () => (editor.shiftHeld.value ? 'toggle' : 'select'),
  disabled: editor.panning,
});

The editor never selects a frame together with a layer inside it: dragged, the layer would move twice. A band captures the pointer as it starts, so a click on a frame's empty space never reaches the frame: Artboard.vue selects the frame when a press there is released where it started.

The layers panel

The tree's items are the design: frames with their children, the top row in front. canNest lets frames take layers and keeps frames at the top level. Rows have keys of their own (row:…), as the layers' ids belong to their draggables on the canvas; the selection is the canvas's, so a row lights up with its layer.

LayersPanel.vue
makeSortableTree(tree, {
  items: editor.layers,
  getKey: (layer) => `row:${layer.id}`,
  canNest: (layer, parent) => parent.kind === 'frame' && layer.kind !== 'frame',
  maxDepth: 1,
  animateChanges: true,
  onDrop: ({ items, from, to }) => {
    // Positions count from the frame's corner: a layer changing frames takes the
    // difference, so it stays where it is on the canvas.
  },
});

onDrop runs before the tree is written, so the editor still knows the frame each layer comes from, and saves the design for undo. A layer moved into a frame that doesn't cover it would be clipped out of sight: it goes to the frame's middle instead. With animateChanges, rows slide for changes made on the canvas and by undo too. A row is typed (type: 'row'), so the canvas and the frames, which take layers and tools, refuse it.

Keyboard

Tab to a layer (it gets selected), then Enter picks it up, the arrows move it 10 pixels (Shift: 40), and Enter drops it in the frame under it. Without a drag, the arrows nudge the selection by a pixel. A tool picks up the same way, and its arrows jump between the canvas and the frames (keyboardMode: 'droppables'). The other shortcuts are listed in the Design panel when nothing is selected.

Read more: Drag and drop · Your own modifier · Rules for a part of the page · Rubber-band selection · Trees