Form Builder in React: Drag-and-Drop Field Configuration
Build a drag-and-drop form builder in React with dynamic field types, live preview, and Tailwind v4 styling — a practical guide for developers who ship real products.
Why Most Form Builders in React Are Annoying to Build
Honestly, building a form builder from scratch is one of those tasks that sounds straightforward until you're three hours deep into drag-and-drop state bugs and wondering why your field order refuses to persist across re-renders. Most tutorials skip the hard parts.
This article covers a full drag-and-drop form builder using dnd-kit v6.1.0 and react-hook-form v7.51.0, with Tailwind v4.0.2 for styling. We'll handle field type registration, live preview, serialization to JSON, and the annoying edge cases nobody warns you about.
We're not going to pretend this is easy. But the pattern we'll build is composable enough that you can drop it into any admin panel, SaaS onboarding flow, or survey tool without rewriting everything from scratch.
The Core Data Model: Field Schema Design
Before you write a single drag handle, you need a field schema. Each field in your form builder should carry its own identity, type metadata, validation rules, and display config. Keep it flat — nested schemas are a pain to serialize and diff.
Here's the base field type we'll use throughout this article. It's intentionally minimal but extensible:
type FieldType = 'text' | 'email' | 'number' | 'select' | 'checkbox' | 'textarea';
interface FormField {
id: string; // nanoid() — never use array index
type: FieldType;
label: string;
placeholder?: string;
required: boolean;
options?: string[]; // only for 'select' fields
validation?: {
min?: number;
max?: number;
pattern?: string;
};
}
interface FormSchema {
id: string;
title: string;
fields: FormField[];
createdAt: string;
}One thing that'll save you hours: use nanoid() for field IDs, not Math.random(). dnd-kit uses these IDs internally as drag item identifiers, and collisions produce silent bugs that are brutal to trace. Always generate IDs once at field-creation time and never mutate them.
Setting Up dnd-kit for Field Reordering
dnd-kit is the right choice here. It's accessible by default, doesn't fight React's rendering model, and handles touch events without extra config. The API is more verbose than some alternatives, but that verbosity buys you control.
Install the packages you actually need — don't pull in the whole suite if you're only doing sortable lists:
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilitiesThe sortable context wraps your field list, and each SortableItem gets a useSortable hook. The critical bit most examples miss: you need to separate the drag overlay from the actual list item, otherwise you get a flash of the item disappearing during drag. Use DragOverlay from @dnd-kit/core and render a clone of the active field there.
import {
DndContext,
DragOverlay,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable';
export function FieldList({ fields, onReorder }: FieldListProps) {
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = fields.findIndex(f => f.id === active.id);
const newIndex = fields.findIndex(f => f.id === over.id);
onReorder(arrayMove(fields, oldIndex, newIndex));
}
setActiveId(null);
}
const activeField = fields.find(f => f.id === activeId);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={e => setActiveId(e.active.id as string)}
onDragEnd={handleDragEnd}
>
<SortableContext items={fields.map(f => f.id)} strategy={verticalListSortingStrategy}>
{fields.map(field => (
<SortableField key={field.id} field={field} />
))}
</SortableContext>
<DragOverlay>
{activeField ? <FieldCard field={activeField} isDragging /> : null}
</DragOverlay>
</DndContext>
);
}Notice the activationConstraint: { distance: 8 }. That 8px threshold prevents accidental drags when the user just clicks a field to edit it. Without it, every click on a field card triggers the drag activation. It's a small setting that makes the UI feel dramatically less janky.
Field Type Panel: Adding New Fields via Click or Drag
The left panel of a form builder is where users pick field types to add. You can make fields addable by click (simpler) or by dragging from the panel into the list (fancier). Start with click — you can always add drag-from-panel later and most users prefer clicking anyway.
Keep the field type definitions in a config array so adding new types is a one-liner. Each type definition includes a default config so new fields get sensible defaults immediately:
const FIELD_TYPES: Array<{
type: FieldType;
label: string;
icon: React.ReactNode;
defaultConfig: Partial<FormField>;
}> = [
{
type: 'text',
label: 'Short Text',
icon: <TextIcon className="w-4 h-4" />,
defaultConfig: { placeholder: 'Enter text...' },
},
{
type: 'email',
label: 'Email',
icon: <MailIcon className="w-4 h-4" />,
defaultConfig: { placeholder: 'you@example.com' },
},
{
type: 'select',
label: 'Dropdown',
icon: <ChevronDownIcon className="w-4 h-4" />,
defaultConfig: { options: ['Option 1', 'Option 2', 'Option 3'] },
},
{
type: 'checkbox',
label: 'Checkbox',
icon: <CheckSquareIcon className="w-4 h-4" />,
defaultConfig: {},
},
{
type: 'textarea',
label: 'Long Text',
icon: <AlignLeftIcon className="w-4 h-4" />,
defaultConfig: { placeholder: 'Write something...' },
},
];The add handler is straightforward — generate an ID, merge the default config, and append to the fields array:
function addField(type: FieldType) {
const def = FIELD_TYPES.find(t => t.type === type)!;
const newField: FormField = {
id: nanoid(),
type,
label: def.label,
required: false,
...def.defaultConfig,
};
setFields(prev => [...prev, newField]);
}Styling the Builder UI with Tailwind v4
The layout is a three-column panel: field type picker on the left, sortable field list in the center, and live preview on the right. In Tailwind v4.0.2, the grid utilities are more expressive and you get better support for subgrid and dynamic column spans.
The drag handles deserve special attention. A lot of builders use full-card drag areas, which conflicts with clicking to edit. Give the drag handle its own element — a GripVertical icon works well — and constrain dragging to that element using dnd-kit's DragHandle pattern.
For the active/dragging state, use a semi-transparent ghost effect. rgba(255,255,255,0.15) over a dark background with backdrop-blur-sm gives a glassmorphism card feel that signals "this thing is being moved" without being visually heavy. If you want to understand the glassmorphism effect in more depth, check out what is glassmorphism — it covers the exact CSS you need.
The field cards themselves get gap-3 between the drag handle and the field content area, and gap-4 between the card and the next card in the list. Keep spacing consistent using the 4px base unit. Don't mix 8px and 10px gaps in the same layout — it's one of those details that makes a UI feel slightly off without the user being able to say why.
Field Editor Panel: Inline Config Without a Modal
Modals for field config are the wrong call. Every time you show a modal, the user loses context of where the field sits in the form. Instead, expand an inline editor panel below the selected field card — it's a better spatial metaphor and it keeps the form visible.
The inline editor should render different controls based on field type. A text field shows label, placeholder, and required toggle. A select field additionally shows an options list editor where items can be added, removed, and reordered. You can reuse the same dnd-kit sortable setup for option reordering — or skip dragging there and use simple up/down buttons, which is faster to build and honestly good enough for most option lists.
Connect the field editor to react-hook-form using useForm with the field's current config as defaultValues. On every change, call your state update function with the new values. Don't debounce — the live preview should update instantly. The performance hit is negligible for form schemas this size.
For animated tabs in React, the same principle applies: tab switching should feel instant. Any animation that adds latency to perceived interaction is a UX anti-pattern, not a feature.
Live Preview Rendering and JSON Export
The right panel renders the actual form based on the current FormSchema. It's a pure function of your state — take the fields array, map over it, render the appropriate input for each type. No magic needed.
Wire the preview form to react-hook-form so validation actually works during preview. This is how you catch config errors early — if your required email field doesn't validate correctly in the preview, it won't work in production either. Make the preview a working form, not a mock.
JSON export is one button. JSON.stringify(schema, null, 2) is all you need for a start. For real products, you'll want to add a schema version field ("schemaVersion": 1) so you can handle migrations later when you add new field types. Versioning from day one is one of those decisions you'll thank yourself for at month six.
If you're building this as part of a larger component system, you might want to look at how animated buttons in React handle state-driven appearance changes — the pattern of keeping visual state as a derived value from a single source of truth applies directly to how your field cards should reflect selection and drag states.
Persisting Form Schemas and Multi-Form Management
What do you persist the schema to? For a SaaS product, it's your database via API. For a standalone tool, localStorage with a schema list indexed by ID works fine. The structure is simple: a Map<string, FormSchema> serialized to JSON.
Multi-form management means you need a forms list view plus the builder itself — two routes. The list shows form names, field counts, and last-modified timestamps. Keep the list view simple. Don't build a drag-to-reorder list of forms unless users explicitly asked for it — that's scope creep that delays shipping.
One real concern: schema migration. When you add a new field type or rename a field property, old schemas break. The safest pattern is a migrateSchema(raw: unknown): FormSchema function that runs on every schema load. It checks schemaVersion and applies transforms in sequence. It's extra work upfront but it's the difference between a tool people trust and one that randomly breaks their forms after an update.
Think about undo/redo too. Not for v1, but plan for it. If you keep your field list in a reducer with an action history array from the start, adding undo later is a matter of implementing UNDO and REDO actions. If you don't, you're looking at a major refactor when a user inevitably asks why they can't undo an accidental field deletion.
FAQ
dnd-kit v6.x is the current standard. It's accessible, works with React 18+, handles keyboard navigation out of the box, and doesn't rely on the HTML5 drag-and-drop API (which has cross-browser quirks). React Beautiful DnD is archived. react-dnd is less maintained. Use dnd-kit.
Set an activationConstraint on the PointerSensor: { distance: 8 } means the user must move 8px before drag activates. This gives click events room to fire before dnd-kit takes over. Also restrict the drag handle to a specific child element using the useDraggable hook's attributes spread only on that element.
Store it as a JSON column in your database. Add a schemaVersion integer field from day one. On load, run every schema through a migration function that checks the version and applies transforms before the UI touches the data. This saves you from breaking existing forms when you rename properties or add required fields.
Yes, and you should. Wrap the preview form in its own FormProvider so validation actually runs. Use useWatch to sync the field values up if you want to show a JSON output of the current form data alongside the preview. Keep the builder state and the preview form state separate — the builder holds the schema, the preview form holds user-entered values.
Store validation rules as a plain object on the field: { required, min, max, pattern }. When rendering the preview form, convert these to react-hook-form's register options: register(field.id, { required: field.required, min: field.validation?.min }). Keep the schema format independent from the validation library so you're not locked in.
Use dnd-kit's useDraggable on each palette item with a type: 'NEW_FIELD' data payload. In the DndContext's onDragEnd, check if the drop target is the field list and whether the dragged item has that payload — if so, insert a new field at the correct index. This is more complex than click-to-add, so only implement it if users explicitly need it. Click-to-add covers 90% of use cases.