@mackehansson/schemaform
Guides

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 engineuseBuilderDnd (schemaform) — is headless and gesture-agnostic. It turns a completed drag into the right useFormBuilder mutations, 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 thin schemaform/dnd-kit glue 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/core

1. 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.

On this page