Docs/Examples

Page builder

A landing page assembled by drag and drop — sections from a palette that turn into the real thing over the page, elements moved between columns, a layers tree on the same data, inline editing, device widths and undo.

Live

Loading the demo…

<script setup lang="ts">
import { provide } from 'vue';
import { DnDProvider, type Active, type AnnouncedPosition, type Announcements } from '@vue-dnd-kit/core';
import Builder from './Builder.vue';
import { BUILDER, LAYERS, PAGE, createBuilder } from './builder';
import { BLOCKS, SECTIONS, excerpt, isColumn, isSection, type ChipData, type LayerNode } from './page';

// Provided above the provider: the overlays it renders copy the page's look from it.
const builder = createBuilder();
provide(BUILDER, builder);

/** What a drag carries, in words: "the Hero section", "a new Pricing section", "the heading “…”". */
const what = (active: Active) => {
  const node = active.items[0]?.item as LayerNode | undefined;
  // A chip of the palette: a plain draggable, with no list item.
  if (!node) return `a new ${BLOCKS[(active.data as ChipData).kind].name.toLowerCase()}`;
  if (isSection(node)) return `${builder.find(node.id) ? 'the' : 'a new'} ${SECTIONS[node.kind].name} section`;
  if (isColumn(node)) return 'the column';
  return `the ${BLOCKS[node.kind].name.toLowerCase()} “${excerpt(node)}”`;
};

/** Where a position of a list is, in words. */
const where = ({ list, index, length }: AnnouncedPosition) => {
  const place = `${index + 1} of ${length}`;
  if (list === PAGE) return `position ${place} on the page`;
  if (list === LAYERS) return `row ${place} of the layers`;
  const at = builder.find(String(list));
  if (!at?.column) return `position ${place}`;
  return `position ${place} in column ${at.section.columns.indexOf(at.column) + 1} of ${SECTIONS[at.section.kind].name}`;
};

const announcements: Partial<Announcements> = {
  onDragStart: ({ active }) => `Picked up ${what(active)}.`,
  onDragOver: ({ active }, { position }) => (position ? `${what(active)}, ${where(position)}` : undefined),
  onDragEnd: ({ active, dropped }, { position }) => (dropped && position ? `Dropped ${what(active)} at ${where(position)}.` : undefined),
  onDragCancel: ({ active }) => `Cancelled. Nothing changed for ${what(active)}.`,
};

// Space picks up and drops. Enter is left to the editor: it edits a text, adds a template.
const keyboard = { keys: { start: ['Space'], drop: ['Space', 'Enter'], cancel: ['Escape'] } };
const instructions =
  'Space picks up; the arrow keys move; Space drops; Escape cancels. Enter edits the focused text, or adds the focused template.';
</script>

<template>
  <DnDProvider :announcements="announcements" :keyboard="keyboard" :screen-reader-instructions="instructions">
    <Builder />
  </DnDProvider>
</template>

Expand the frame to the full screen: the palette docks on the left, the layers on the right, and the page takes the room in between. Narrower, they share a drawer opened from the top bar.

What to look at

  • Canvas.vue: the page is a sortable list of sections with placeholder: 'indicator'. It stays still while a section travels, and a line between two sections says where it goes: sliding 500px sections out of the way on every slot would shake the whole page. dropzone hands the drop to the whole canvas, so a section let go beside or below the page still lands.
  • ColumnList.vue: every column of every section is a list of the group blocks. Elements move between columns and sections and sort live: the page shows the result while you drag. Each column is a component of its own, and the columns are lists inside the items of another list.
  • Palette.vue: the section templates are a list of the page's group with pull: 'clone', put: false and sort: false: it hands out copies and takes nothing back. The elements below them (ElementChip.vue) are plain draggables, and a column makes the block with createItem.
  • Layers.vue: the same page as a sortable tree, on the same data. A row moved there moves on the page, and the other way round.
  • builder.ts: the page, the selection, the device and the history. Drops write the page through the lists; the toolbar's buttons and the shortcuts change it here. Each change is a step of undo.

A section in hand is the real section

SectionGhost.vue is the overlay of both the page's sections and the palette's templates. Over the page (to.list is the page's list), it renders the section itself, at the page's width, scaled down under the pointer. When the drop is decided (status: 'dropping'), it scales back to 1 while the box flies to the section's place: it lands on it exactly. The drop waits for that transition, so nothing jumps at the end.

SectionGhost.vue
<script setup lang="ts">
const props = defineProps<{ items: DragItem[]; status: DnDStatus; to: DropDestination | null }>();
const section = computed(() => props.items[0]!.item as Section);
const fromPalette = computed(() => !find(section.value.id));
const onPage = computed(() => props.to?.list === PAGE);

const look = computed(() => {
  if (fromPalette.value && !onPage.value) return 'card'; // still the palette's card
  return props.status === 'dropping' ? 'landing' : 'ghost';
});
</script>

<template>
  <DragPreview v-if="look === 'card'" :id="items[0]!.id" :style="{ width: '100%', height: '100%' }" />
  <div v-else class="replica" :class="look" :style="{ width: `${pageWidth}px`, '--shrink': shrink }">
    <SectionView :section="section" />
  </div>
</template>

<style scoped>
.replica { transform-origin: 0 0; scale: var(--shrink); transition: scale 0.25s ease; }
.replica.landing { scale: 1; }
</style>

Away from the page, a template stays what it was in the palette: DragPreview shows the library's copy of its card. The section's place on the page is the slot: while you drag it waits there, faded; dropped, its new place stays empty until the overlay has landed on it.

PageSection.vue
.page-section[data-dnd-placeholder] { opacity: 0; }
html[data-dnd-dragging] .page-section[data-dnd-placeholder] { opacity: 0.35; }

Each section draws the line from its own indicator, and says what the drop does: Move Features here, or Add Pricing here for a template.

Elements: in the colors of the section they go into

Blocks are dragged by their whole body; a section by the grip of its tag, as its body is content to click and drag. BlockGhost.vue, the overlay of blocks and chips, reads the column it would go into from to and takes its section's colors: a button over the dark footer turns light before you let go. The box has the size of the block's slot there (overlayFit: 'slot', the default), and the block lays out in it as it will after the drop.

BlockGhost.vue
// The column it would go into (`to.list` is the column's id): its section's tone.
const look = computed(() => {
  const at = props.to && find(String(props.to.list));
  return at ? { ...TONES[SECTIONS[at.section.kind].tone], ...typeScale(pageWidth.value) } : null;
});

A chip of the palette is a plain draggable that carries its kind. Each column makes a block from it, and the new block sits in the column, rendered by the column's own template, until you let go or move on:

ColumnList.vue
makeSortableList(list, {
  id: props.column.id,
  items: blocks,
  group: 'blocks',
  overlay: BlockGhost,
  createItem: (active) => {
    const kind = (active.data as ChipData | undefined)?.kind;
    return kind ? createBlock(BLOCKS[kind].spec) : undefined;
  },
});

blocks is a writable computed over props.column.blocks rather than toRef: undo puts new objects in the page, and the column must follow them.

Templates: copies out, nothing in

Palette.vue
makeSortableList(list, {
  id: 'templates',
  items: templates,
  group: { name: 'sections', pull: 'clone', put: false },
  sort: false,
  clone: (template) => createSection(template.kind), // new ids all the way down
  overlay: SectionGhost,
});

The templates are real sections, so their cards show them (zoomed out) and the overlay can render them. A click on a card, or Enter, adds the section after the selected one: the page can be built without dragging at all.

Layers: the same page as a tree

Layers.vue
makeSortableTree(tree, {
  id: LAYERS,
  items: sections as Ref<LayerNode[]>,
  // Keys of their own: the canvas's lists already drag these ids.
  getKey: (node) => `layer-${node.id}`,
  getChildren: (node) => (isSection(node) ? node.columns : isColumn(node) ? node.blocks : undefined),
  setChildren: (node, children) => { … },
  // Blocks go into columns, nothing into blocks, sections stay at the top.
  canNest: (node, parent) => levelOf(node) === levelOf(parent) + 1,
  maxDepth: 2,
  placeholder: 'indicator',
  // A tree takes any row at its top level: a block let go there flies back.
  onBeforeDrop: (event) => (event.to.parentKey !== null || event.items.every(isSection) ? 'accept' : 'reject'),
});

Keys are shared by the whole provider, so the rows get keys of their own. The columns are rows too, disabled: they take blocks and stay where they are. Where a block can't go, between two sections, its line turns red. Hovering a row outlines its node on the page; selecting a node on the page opens its section here and scrolls its row into view.

Editing in place, and the keyboard

A block drags from anywhere on it, so its texts aren't editable until you double-click it: then they are contenteditable, and canDrag turns the drags down so a press places the caret. EditableText.vue writes the DOM from the model only while the text isn't being edited, so the caret never jumps, and undo still shows.

The provider keeps Space for drags and leaves Enter to the editor:

index.vue
<DnDProvider
  :keyboard="{ keys: { start: ['Space'], drop: ['Space', 'Enter'], cancel: ['Escape'] } }"
  :announcements="announcements"
  :screen-reader-instructions="instructions"
>

Tab to a block: Space picks it up and the arrows move it through its column and into the next ones; Enter edits it. A section moves by its grip the same way, or by the arrows of its toolbar. Ctrl Z undoes, Delete removes, Ctrl D duplicates. Screen readers hear "the Hero section, position 3 of 7 on the page" rather than ids.

Undo, and changes without a drag

Every drop is one step of the history: a monitor saves the page once the lists have written it.

Builder.vue
useDnDMonitor({ onDragEnd: () => builder.commit() });

Undo puts a snapshot back. The lists have animateChanges on, so what the snapshot moved slides there, as do the sections the toolbar moves up and down; added sections fade in and deleted ones out.

Read more: Sortable lists · Lists that share items · Trees · The overlay · Animations · Accessibility