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.
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 }andsort: false. Its items are fields like the form's, so a copy dragged into a row is a field like the others;clonegives 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. Itsputrefuses 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 withevent.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.
// 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:
<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.
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.
<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.vuerenders a field the same way on the canvas and in the preview: aUFormFieldaround its control. On the canvas it sits in aninertelement, so a press on an input starts a drag instead of typing, and Tab skips the inputs. The card's toolbar (aUFieldGroupof buttons with tooltips) stays out of the overlay's copy withdata-dnd-no-clone. - States are Tailwind variants. The library marks its states with attributes, which Tailwind
reads as variants:
data-[dnd-placeholder]:outline-dashedon the field being moved,in-data-[dnd-overlay]:bg-defaulton a row in hand,in-data-[dnd-dragging]:pointer-events-autoon 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.tsmakes a Zod schema from the fields, andUFormvalidates the answers with it:nameties eachUFormFieldto 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
reactivestate, 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.
@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