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.
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.vueandArtboard.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 ismakeSortableTreeon 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;makeSelectionAreadraws the rubber band, andmakeDragAreagives every drag that starts on the canvas its rules.editor.ts,geometry.tsanddesign.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().
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.
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:
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.
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.
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').
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.
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