Docs/Examples

Form builder

A form builder made with Nuxt UI — fields dragged from a palette, set side by side in rows, edited in a panel, then filled in a live preview with validation and exported as Vue code.

Live

Loading the demo…

<script setup lang="ts">
import { provide } from 'vue';
import { DnDProvider, type Active, type Announcements } from '@vue-dnd-kit/core';
import Builder from './Builder.vue';
import { FORM, KINDS, TRASH, createForm, type Field, type Row } from './form';

// One form for the whole builder: its lists, its panels and the announcements read it.
const form = createForm();
provide(FORM, form);

// Space picks a field up and drops it; Enter is left to open a field's settings.
const KEYBOARD = { keys: { start: ['Space'], drop: ['Space'] } };

// Screen readers hear what moves and where it would go, in words: "Email: row 2, column 1 of 2".
const nameOf = ({ items: [first] }: Active) => {
  const item = first?.item as Field | Row | undefined;
  if (!item) return 'Item';
  if ('fields' in item) return `Row ${form.state.rows.findIndex((row) => row.id === item.id) + 1}`;
  return first!.type.includes('new-field') ? `New ${KINDS[item.kind].name.toLowerCase()} field` : item.label || 'Untitled field';
};
const announcements: Partial<Announcements> = {
  onDragStart: ({ active }) => `${nameOf(active)} picked up. Arrows move it, Space drops it, Escape cancels.`,
  onDragOver: ({ active }, { position }) => (position ? `${nameOf(active)}: ${form.describe(position)}.` : undefined),
  onDragEnd: ({ active, over }) => (over?.id === TRASH ? `${nameOf(active)} deleted.` : `${nameOf(active)} dropped.`),
  onDragCancel: ({ active }) => `${nameOf(active)} put back.`,
};
</script>

<template>
  <DnDProvider :keyboard="KEYBOARD" :announcements="announcements">
    <Builder />
  </DnDProvider>
</template>

Every control here is a Nuxt UI component: the fields of the form (UInput, USelect, URadioGroup, UInputRating, UFileUpload…), the panels around them, the preview's UForm. Vue DnD Kit moves them. Expand the demo to full screen: the palette, the form and the settings then sit side by side.

What to look at

  • PaletteGroup.vue: a section of the palette is a sortable list that only gives copies: group: { name: 'fields', pull: 'clone', put: false } and sort: false. Its items are fields like the form's, so a copy dragged into a row is a field like the others; clone gives it a key of its own. A click (or Enter) adds the field without a drag.
  • FormRow.vue: a row is two things at once, as a kanban column is: an item of the form's list of rows, dragged by its grip, and a list of up to three fields in the palette's group. Its put refuses a fourth: over a full row the field in hand turns red and says why, and a drop there is cancelled.
  • NewRowZone.vue: drop a field on the gap between two rows, or below the last one, and it gets a row of its own. Those places are lists too: empty lists of the same group, whose ids say where the new row goes.
  • FieldCard.vue: the rows are in indicator mode (placeholder: 'indicator'), so nothing moves while a field is dragged: a line beside a field shows where it will go. In a narrow builder the fields of a row stack, and the line goes above or below them.
  • Trash.vue: while a field of the form is dragged, the palette becomes a trash: a plain drop target that takes the form's fields only (accept: 'fields', their group's name) and removes them with event.source.remove().
  • OptionList.vue: the options of a dropdown or a multiple choice are a sortable list of their own in the settings panel, dragged by their grips so their text stays editable.
  • form.ts: the form, and the only code that changes it. The lists read it and never write it, so Undo and Redo see every change, drops included.

A row of fields is a list in a list

The rows of the form and the fields of each row are lists of two groups, rows and fields. A drag only ever enters the lists of its own group: a field never lands among the rows, and a row never inside another row, however they are nested.

FormRow.vue
// The row is an item of the form's rows, dragged by its grip...
makeSortableItem(el, { item: () => props.row, handle: grip });

// ...and a list of up to three fields side by side, in the group of the palette.
const { items, isOver, isAccepting } = makeSortableList(list, {
  id: rowList(props.row.id),
  items: () => props.row.fields,
  group: { name: 'fields', put: ({ keys }) => props.row.fields.length + keys.length <= MAX_COLUMNS },
  strategy: () => (props.stacked ? 'vertical' : 'horizontal'),
  placeholder: 'indicator',
  overlay: FieldOverlay,
  clone: cloneField, // Alt-drag copies a field
  animateChanges: true,
  onDrop: form.drop,
});

A new row needs a place to be dropped on. Each row holds a band over the gap above it, and the space below the last row is one more such place: an empty list of the fields group, which never shows an item. Over a band the list is isOver, and the band draws its line. The bands take the pointer only while something is dragged (html[data-dnd-dragging]), so they never get in the way of a click:

NewRowZone.vue
<script setup lang="ts">
const NONE: readonly Field[] = [];
const { isOver, isAccepting } = makeSortableList(el, {
  id: newRowList(props.before), // 'new:<row id>', or 'new:end'
  items: () => NONE,
  group: 'fields',
  placeholder: 'indicator',
  // A band across the page: up and down are the keys that cross it, to the next list.
  strategy: 'horizontal',
  onDrop: form.drop,
});
</script>

<template>
  <div
    ref="el"
    class="pointer-events-none absolute inset-x-0 -top-[18px] z-10 h-6 in-data-[dnd-dragging]:pointer-events-auto"
  >
    <span class="… bg-primary" :class="isOver ? 'opacity-100' : 'opacity-0'" />
  </div>
</template>

The keyboard finds the bands as well. A keyboard drag goes from list to list by their places on the page, with the arrows across a list: up and down for the rows, which are horizontal, and for the bands, horizontal too. So ArrowUp and ArrowDown visit the rows and the gaps between them in turn.

One store, one Undo: lists that never write

Every list of the builder gets its items from a getter, so the library never writes them (a controlled list). Their onDrop is form.drop: it cancels the write and hands event.result, the new arrays of every list the drop changes, to the form. The list ids say what each array is: the fields of a row, the order of the rows, or a row to make.

form.ts
function apply(result: ReadonlyMap<Key, unknown[]>) {
  commit(() => {
    const created: [before: string, fields: Field[]][] = [];
    for (const [list, items] of result) {
      const [type, id] = String(list).split(':') as [string, string];
      if (list === ROWS) state.rows = items as Row[];
      else if (type === 'row') state.rows.find((row) => row.id === id)!.fields = items as Field[];
      else if (type === 'new' && items.length) created.push([id, items as Field[]]);
    }
    for (const [before, items] of created) {
      const index = state.rows.findIndex((row) => row.id === before);
      state.rows.splice(index < 0 ? state.rows.length : index, 0, row(...items));
    }
  });
}

/** `onDrop` of every list: applies the drop, and selects a field that was just added. */
function drop(event: SortableDropEvent<unknown>) {
  event.preventDefault();
  apply(event.result);
  if (event.mode === 'copy') selectedId.value = (event.items[0] as Field).id;
  forgetGone();
}

commit saves a snapshot of the form for Undo, makes the change, and removes the rows a move left empty, all before the drop lands, so the overlay flies to where the field really is. The trash takes the same road: event.source.remove() runs the row's onDrop with the field taken out (to.index is -1). Typing in the settings goes through commit too, and keystrokes in one setting share a key: a label typed letter by letter is a single step of Undo.

The field in hand

The palette and the rows share one overlay component. It shows the library's copy of what is dragged (DragPreview: a chip from the palette, the whole field from the form, its Nuxt UI control included) in a frame, with a badge for what a drop would do: copy (Alt), delete over the trash, nothing over a full row.

FieldOverlay.vue
<script setup lang="ts">
const props = defineProps<{ items: DragItem[]; over: Over | null; accepted: boolean; status: DnDStatus; mode: DragMode }>();

const entry = computed(() => props.items[0]!);
const state = computed(() => {
  if (props.status === 'dropping') return 'landing';
  if (props.over?.id === TRASH) return 'delete';
  // A row that refuses the field: it is full.
  if (props.over && !props.accepted) return 'full';
  // Palette fields are copies too, but they say nothing: adding is what they do.
  if (props.mode === 'copy' && !entry.value.type.includes('new-field')) return 'copy';
  return 'move';
});
</script>

<template>
  <!-- LOOKS: lifted and tilted, red when refused, smaller over the trash, flat as it lands. -->
  <div class="relative size-full rounded-md bg-default ring-2 …" :class="LOOKS[state]">
    <DragPreview :id="entry.id" class="rounded-md" :style="{ width: '100%', height: '100%' }" />
    <UBadge v-if="badge" :label="badge.label" :icon="badge.icon" :color="badge.color" size="sm" />
  </div>
</template>

The palette's items have a type of their own, 'new-field': the overlay tells them apart by it, and the trash, which accepts 'fields', doesn't take them.

With Nuxt UI

The docs are a Nuxt app with the @nuxt/ui module, and Nuxt's component auto-import reaches the examples too: these files use <UInput>, <UFormField> or <UTabs> without importing them, as a Nuxt UI project does. A Vue app with Nuxt UI's Vite plugin auto-imports them the same way; without it, import each one: import UButton from '@nuxt/ui/components/Button.vue'. The types come from @nuxt/ui (import type { FormSubmitEvent } from '@nuxt/ui').

  • While you build, fields are pictures. FieldView.vue renders a field the same way on the canvas and in the preview: a UFormField around its control. On the canvas it sits in an inert element, so a press on an input starts a drag instead of typing, and Tab skips the inputs. The card's toolbar (a UFieldGroup of buttons with tooltips) stays out of the overlay's copy with data-dnd-no-clone.
  • States are Tailwind variants. The library marks its states with attributes, which Tailwind reads as variants: data-[dnd-placeholder]:outline-dashed on the field being moved, in-data-[dnd-overlay]:bg-default on a row in hand, in-data-[dnd-dragging]:pointer-events-auto on the bands. The colors are Nuxt UI's semantic ones (bg-default, ring-primary, text-error): light and dark mode need nothing more.
  • The preview is a real UForm. schema.ts makes a Zod schema from the fields, and UForm validates the answers with it: name ties each UFormField to its rule, and the messages show under the fields. Send the form to see what it submits.
  • The export writes the same form as a component of its own: Nuxt UI controls, a Zod schema, a reactive state, and the rows as grids that stack on phones.
  • The layout follows the builder's width (useElementSize), not the window's: three panels in full screen; on the page the settings take the palette's place; narrower, the palette becomes a strip of chips above the form and the settings a sheet over it. Container queries (@lg:) do the rest.
The StackBlitz button opens the files in a Vite project set up for Nuxt UI, as its docs do it for Vue: the @nuxt/ui/vite plugin, Tailwind CSS, and UApp around the example. In a Nuxt app with the @nuxt/ui module, copy the folder and render index.vue on a page; the form needs zod too.

Keyboard and screen readers

Tab to a field and press Space to pick it up. ArrowLeft and ArrowRight move it along its row, ArrowUp and ArrowDown to the other rows and to the gaps between them (a new row), Space drops it, Escape puts it back. The provider takes only Space for drags (keyboard: { keys: { start: ['Space'], drop: ['Space'] } }), which leaves Enter to open a field's settings. Delete removes a field, Ctrl D duplicates it, Ctrl Z undoes.

The provider's announcements speak the builder's words: "Work email: row 2, column 2 of 2", "New date field: a new row above row 3". form.describe turns the position the library gives (a list id and an index) into those words.

Read more: Lists that share items · An insertion line · The overlay · Styling: Tailwind and UI kits · Accessibility