Add Drag and Drop
Wire dnd-kit drag-and-drop into your own form builder with the headless useBuilderDnd engine.
Build a Form Builder ends with a click-to-add palette. This guide adds drag-and-drop: drag fields from the palette onto a canvas and reorder them — using your own dnd-kit setup.
The design (ADR-0019) keeps drag-and-drop a building block, not a feature locked inside the shadcn <FormBuilder>:
- The engine —
useBuilderDnd(schemaform) — is headless and gesture-agnostic. It turns a completed drag into the rightuseFormBuildermutations, handling the fiddly index-shift and envelope-integrity rules for you. - The gesture library is yours. You bring your own
<DndContext>, sensors, and styling; a thinschemaform/dnd-kitglue translates between dnd-kit and the engine. - The easy path — shadcn
<DropZone>/<DraggableItem>— are ready-made styled pieces over that glue.
Prerequisites
You have a builder on useFormBuilder (from the previous guide). Install dnd-kit — it is an optional peer of schemaform, so you only add it when you want it:
npm install @dnd-kit/core1. The drag vocabulary
Every drag is described by two values the engine understands:
- A
DragSource— what is being dragged: a palette item to create ({ kind: "palette-field", widget }), an existing canvas node to move ({ kind: "canvas", pageIndex, path }), and so on. - A
DropTarget— where it lands:{ pageIndex, parentPath, insertIndex }.
Your gesture layer only has to carry a source with each draggable, attach a target to each drop slot, and tell the engine when a drop happens. The engine does the rest.
2. Wire up the engine and <DndContext>
useBuilderDnd takes your builder and a palette of authoring defaults — what schema and label a dropped field gets. Derive it from your renderer's Registry with paletteFromRegistry, or write it by hand:
import { DndContext } from "@dnd-kit/core";
import { useFormBuilder, useBuilderDnd } from "@mackehansson/schemaform";
import { onDndKitEnd } from "@mackehansson/schemaform-dnd-kit";
import { defaultRegistry, paletteFromRegistry } from "@mackehansson/schemaform-shadcn";
const palette = paletteFromRegistry(defaultRegistry);
// or: const palette = [{ widget: "text", label: "Text", defaultSchema: { type: "string" } }];
export function MyBuilder() {
const builder = useFormBuilder({ initialDefinition: emptyDefinition });
const dnd = useBuilderDnd(builder, { palette });
return (
<DndContext onDragEnd={onDndKitEnd(dnd)}>
<Palette />
<Canvas definition={builder.definition} />
</DndContext>
);
}onDndKitEnd(dnd) is the entire bridge: it reads the source off the dragged item and the target off whatever it was dropped on, then calls dnd.commit (or dnd.commitPageTab for page tabs). Drops that miss every target are ignored.
3. Make palette items draggable
Carry a DragSource with each palette entry. The dndKitSource helper builds the data bag dnd-kit hands back on drop:
import { useDraggable } from "@dnd-kit/core";
import { dndKitSource } from "@mackehansson/schemaform-dnd-kit";
function PaletteItem({ widget, label }: { widget: string; label: string }) {
const { setNodeRef, attributes, listeners } = useDraggable({
id: `palette-${widget}`,
data: dndKitSource({ kind: "palette-field", widget }),
});
return (
<div ref={setNodeRef} {...attributes} {...listeners}>
{label}
</div>
);
}4. Add drop slots to the canvas
Put a drop slot between each canvas item (and one trailing slot to append). Each carries the DropTarget for its position:
import { useDroppable } from "@dnd-kit/core";
import { dndKitTarget } from "@mackehansson/schemaform-dnd-kit";
function DropSlot({ index }: { index: number }) {
const { setNodeRef, isOver } = useDroppable({
id: `drop-${index}`,
data: dndKitTarget({ pageIndex: 0, parentPath: [], insertIndex: index }),
});
return <div ref={setNodeRef} data-over={isOver} className="drop-slot" />;
}Dropping a palette field on the slot at insertIndex: 0 creates the schema property and the Control at the top of the page — the engine calls addField for you.
5. Make canvas items draggable to reorder
Give each existing field a canvas source. Dropping it on another slot moves it — the engine corrects the index shift that removing-then-reinserting causes, and refuses to drop a container into its own subtree:
const { setNodeRef, attributes, listeners } = useDraggable({
id: `canvas-${index}`,
data: dndKitSource({ kind: "canvas", pageIndex: 0, path: [index] }),
});To move a field onto another page, make your page tabs droppable with dndKitPageTab(destPageIndex); onDndKitEnd routes those to commitPageTab.
The easy path: shadcn components
schemaform ships <DraggableItem> and <DropZone> — the same wiring as above, styled like the reference builder's drop gaps. If you use the shadcn renderer, you can skip the raw hooks:
import { DndContext } from "@dnd-kit/core";
import { onDndKitEnd } from "@mackehansson/schemaform-dnd-kit";
import { DraggableItem, DropZone } from "@mackehansson/schemaform";
<DndContext onDragEnd={onDndKitEnd(dnd)}>
<DraggableItem id="palette-text" source={{ kind: "palette-field", widget: "text" }}>
Text
</DraggableItem>
{/* between canvas rows */}
<DropZone id="drop-1" target={{ pageIndex: 0, parentPath: [], insertIndex: 1 }} />
</DndContext>A runnable version of this builder lives at apps/web/app/byo-builder in the repository.
Not using dnd-kit?
The engine never learns which library called it — it only ever sees a DragSource and a DropTarget. The native HTML5 drag API (or any other gesture library) is just a different glue layer over the same dnd.commit. That is exactly how the reference <FormBuilder> drives the same engine without dnd-kit.