Loading…
Loading…
Learn how to build a drag-and-drop form builder in React using dnd-kit, with field configuration panels, form preview, JSON schema export, and conditional logic support.
Tags
Using dnd-kit with a field registry pattern, you can build a fully configurable drag-and-drop form builder that exports clean JSON schemas and handles conditional logic without third-party form platforms.
Start by creating a new React project and installing the required dnd-kit packages.
npx create-react-app form-builder --template typescript
cd form-builder
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
npm install uuid
npm install -D @types/uuiddnd-kit is split into modular packages. @dnd-kit/core provides the drag-and-drop engine, @dnd-kit/sortable adds sortable list behavior, and @dnd-kit/utilities includes helpful CSS transform utilities.
Before building any UI, define the types that represent your form schema. This is the data structure your builder will produce.
// types/form.ts
export type FieldType = "text" | "textarea" | "select" | "checkbox" | "number" | "email";
export interface FieldOption {
label: string;
value: string;
}
export interface FormField {
id: string;
type: FieldType;
label: string;
placeholder?: string;
required: boolean;
options?: FieldOption[]; // For select fields
defaultValue?: string;
validation?: {
minLength?: number;
maxLength?: number;
pattern?: string;
};
}
export interface FormSchema {
title: string;
description?: string;
fields: FormField[];
}Instead of hardcoding field types throughout the app, create a registry that maps each field type to its configuration defaults and its render component.
// registry/fieldRegistry.ts
import { FieldType, FormField } from "../types/form";
import { v4 as uuidv4 } from "uuid";
export const fieldDefaults: Record<FieldType, Omit<FormField, "id">> = {
text: { type: "text", label: "Text Field", placeholder: "Enter text...", required: false },
textarea: { type: "textarea", label: "Text Area", placeholder: "Enter long text...", required: false },
select: {
type: "select",
label: "Dropdown",
required: false,
options: [{ label: "Option 1", value: "option1" }],
},
checkbox: { type: "checkbox", label: "Checkbox", required: false, defaultValue: "false" },
number: { type: "number", label: "Number Field", placeholder: "0", required: false },
email: { type: "email", label: "Email Field", placeholder: "you@example.com", required: false },
};
export function createField(type: FieldType): FormField {
return { id: uuidv4(), ...fieldDefaults[type] };
}The palette is the sidebar where users pick fields to drag onto the canvas. Each palette item acts as a drag source.
// components/FieldPalette.tsx
import { useDraggable } from "@dnd-kit/core";
import { FieldType } from "../types/form";
const fieldTypes: { type: FieldType; label: string; icon: string }[] = [
{ type: "text", label: "Text Input", icon: "T" },
{ type: "textarea", label: "Text Area", icon: "P" },
{ type: "select", label: "Dropdown", icon: "V" },
{ type: "checkbox", label: "Checkbox", icon: "C" },
{ type: "number", label: "Number", icon: "#" },
{ type: "email", label: "Email", icon: "@" },
];
function DraggablePaletteItem({ type, label, icon }: { type: FieldType; label: string; icon: string }) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `palette-${type}`,
data: { type, fromPalette: true },
});
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
className={`palette-item ${isDragging ? "dragging" : ""}`}
>
<span className="palette-icon">{icon}</span>
<span>{label}</span>
</div>
);
}
export function FieldPalette() {
return (
<div className="field-palette">
<h3>Form Fields</h3>
{fieldTypes.map((field) => (
<DraggablePaletteItem key={field.type} {...field} />
))}
</div>
);
}The canvas is where dropped fields live and can be reordered. This uses @dnd-kit/sortable for reordering.
// components/FormCanvas.tsx
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { FormField } from "../types/form";
interface SortableFieldProps {
field: FormField;
isSelected: boolean;
onSelect: (id: string) => void;
onRemove: (id: string) => void;
}
function SortableField({ field, isSelected, onSelect, onRemove }: SortableFieldProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: field.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`canvas-field ${isSelected ? "selected" : ""}`}
onClick={() => onSelect(field.id)}
>
<div className="field-drag-handle" {...attributes} {...listeners}>
☰
</div>
<div className="field-preview">
<label>{field.label}{field.required && " *"}</label>
{field.type === "text" && <input type="text" placeholder={field.placeholder} disabled />}
{field.type === "textarea" && <textarea placeholder={field.placeholder} disabled />}
{field.type === "select" && (
<select disabled>
{field.options?.map((opt) => (
<option key={opt.value}>{opt.label}</option>
))}
</select>
)}
{field.type === "checkbox" && <input type="checkbox" disabled />}
{field.type === "number" && <input type="number" placeholder={field.placeholder} disabled />}
{field.type === "email" && <input type="email" placeholder={field.placeholder} disabled />}
</div>
<button className="remove-btn" onClick={() => onRemove(field.id)}>X</button>
</div>
);
}
interface FormCanvasProps {
fields: FormField[];
selectedFieldId: string | null;
onSelectField: (id: string) => void;
onRemoveField: (id: string) => void;
}
export function FormCanvas({ fields, selectedFieldId, onSelectField, onRemoveField }: FormCanvasProps) {
return (
<div className="form-canvas">
<SortableContext items={fields.map((f) => f.id)} strategy={verticalListSortingStrategy}>
{fields.length === 0 && (
<div className="canvas-empty">Drag fields here to build your form</div>
)}
{fields.map((field) => (
<SortableField
key={field.id}
field={field}
isSelected={selectedFieldId === field.id}
onSelect={onSelectField}
onRemove={onRemoveField}
/>
))}
</SortableContext>
</div>
);
}When a user clicks a field on the canvas, a side panel shows its configurable properties.
// components/FieldConfigPanel.tsx
import { FormField, FieldOption } from "../types/form";
interface FieldConfigPanelProps {
field: FormField;
onUpdate: (id: string, updates: Partial<FormField>) => void;
}
export function FieldConfigPanel({ field, onUpdate }: FieldConfigPanelProps) {
const handleChange = (key: keyof FormField, value: unknown) => {
onUpdate(field.id, { [key]: value });
};
const handleOptionChange = (index: number, key: keyof FieldOption, value: string) => {
const updatedOptions = [...(field.options || [])];
updatedOptions[index] = { ...updatedOptions[index], [key]: value };
onUpdate(field.id, { options: updatedOptions });
};
const addOption = () => {
const options = [...(field.options || []), { label: "New Option", value: `option${Date.now()}` }];
onUpdate(field.id, { options });
};
const removeOption = (index: number) => {
const options = (field.options || []).filter((_, i) => i !== index);
onUpdate(field.id, { options });
};
return (
<div className="config-panel">
<h3>Field Settings</h3>
<div className="config-group">
<label>Label</label>
<input
type="text"
value={field.label}
onChange={(e) => handleChange("label", e.target.value)}
/>
</div>
<div className="config-group">
<label>Placeholder</label>
<input
type="text"
value={field.placeholder || ""}
onChange={(e) => handleChange("placeholder", e.target.value)}
/>
</div>
<div className="config-group">
<label>
<input
type="checkbox"
checked={field.required}
onChange={(e) => handleChange("required", e.target.checked)}
/>
Required
</label>
</div>
{field.type === "select" && (
<div className="config-group">
<label>Options</label>
{field.options?.map((opt, i) => (
<div key={i} className="option-row">
<input
value={opt.label}
onChange={(e) => handleOptionChange(i, "label", e.target.value)}
placeholder="Label"
/>
<input
value={opt.value}
onChange={(e) => handleOptionChange(i, "value", e.target.value)}
placeholder="Value"
/>
<button onClick={() => removeOption(i)}>-</button>
</div>
))}
<button onClick={addOption}>Add Option</button>
</div>
)}
{(field.type === "text" || field.type === "textarea") && (
<div className="config-group">
<label>Max Length</label>
<input
type="number"
value={field.validation?.maxLength || ""}
onChange={(e) =>
handleChange("validation", {
...field.validation,
maxLength: parseInt(e.target.value) || undefined,
})
}
/>
</div>
)}
</div>
);
}The main component sets up the DndContext and manages the state for fields, selection, and drag events.
// App.tsx
import { useState, useCallback } from "react";
import {
DndContext,
DragEndEvent,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { arrayMove } from "@dnd-kit/sortable";
import { FormField, FieldType, FormSchema } from "./types/form";
import { createField } from "./registry/fieldRegistry";
import { FieldPalette } from "./components/FieldPalette";
import { FormCanvas } from "./components/FormCanvas";
import { FieldConfigPanel } from "./components/FieldConfigPanel";
export default function App() {
const [fields, setFields] = useState<FormField[]>([]);
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
// Dropping from palette to canvas
if (active.data.current?.fromPalette) {
const fieldType = active.data.current.type as FieldType;
const newField = createField(fieldType);
setFields((prev) => [...prev, newField]);
setSelectedFieldId(newField.id);
return;
}
// Reordering within canvas
if (active.id !== over.id) {
setFields((prev) => {
const oldIndex = prev.findIndex((f) => f.id === active.id);
const newIndex = prev.findIndex((f) => f.id === over.id);
return arrayMove(prev, oldIndex, newIndex);
});
}
}, []);
const handleRemoveField = (id: string) => {
setFields((prev) => prev.filter((f) => f.id !== id));
if (selectedFieldId === id) setSelectedFieldId(null);
};
const handleUpdateField = (id: string, updates: Partial<FormField>) => {
setFields((prev) =>
prev.map((f) => (f.id === id ? { ...f, ...updates } : f))
);
};
const selectedField = fields.find((f) => f.id === selectedFieldId);
const exportSchema = (): FormSchema => ({
title: "My Form",
fields: fields.map(({ id, ...rest }) => ({ id, ...rest })),
});
return (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<div className="builder-layout">
<FieldPalette />
<FormCanvas
fields={fields}
selectedFieldId={selectedFieldId}
onSelectField={setSelectedFieldId}
onRemoveField={handleRemoveField}
/>
{selectedField && (
<FieldConfigPanel field={selectedField} onUpdate={handleUpdateField} />
)}
</div>
<button onClick={() => console.log(JSON.stringify(exportSchema(), null, 2))}>
Export JSON Schema
</button>
</DndContext>
);
}The PointerSensor with a distance constraint of 5 pixels prevents accidental drags when clicking. This is important because the canvas fields are both clickable (to select) and draggable (to reorder). Without the distance constraint, every click would trigger a drag event.
The schema export produces a clean JSON object that can be stored in a database and rendered dynamically by a separate form renderer.
// components/FormRenderer.tsx
import { FormSchema, FormField } from "../types/form";
import { useState } from "react";
function RenderField({ field }: { field: FormField }) {
const [value, setValue] = useState(field.defaultValue || "");
switch (field.type) {
case "text":
case "email":
case "number":
return (
<div className="form-group">
<label>{field.label}{field.required && " *"}</label>
<input
type={field.type}
placeholder={field.placeholder}
value={value}
onChange={(e) => setValue(e.target.value)}
required={field.required}
maxLength={field.validation?.maxLength}
/>
</div>
);
case "textarea":
return (
<div className="form-group">
<label>{field.label}{field.required && " *"}</label>
<textarea
placeholder={field.placeholder}
value={value}
onChange={(e) => setValue(e.target.value)}
required={field.required}
/>
</div>
);
case "select":
return (
<div className="form-group">
<label>{field.label}{field.required && " *"}</label>
<select value={value} onChange={(e) => setValue(e.target.value)} required={field.required}>
<option value="">Select...</option>
{field.options?.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
);
case "checkbox":
return (
<div className="form-group">
<label>
<input
type="checkbox"
checked={value === "true"}
onChange={(e) => setValue(String(e.target.checked))}
/>
{field.label}
</label>
</div>
);
default:
return null;
}
}
export function FormRenderer({ schema }: { schema: FormSchema }) {
return (
<form onSubmit={(e) => { e.preventDefault(); alert("Submitted!"); }}>
<h2>{schema.title}</h2>
{schema.fields.map((field) => (
<RenderField key={field.id} field={field} />
))}
<button type="submit">Submit</button>
</form>
);
}Your form builder now has three panels working in concert:
The entire form state lives in a single fields array. Dragging from the palette appends a new field. Dragging within the canvas reorders using arrayMove. Clicking a field reveals its config panel. And the export button serializes the current state into a portable JSON schema.
To persist forms, save the exported JSON to your database. To render submitted forms, pass the JSON schema to FormRenderer and it dynamically builds the form from the schema.
conditions array to each field that references other field values to show/hide fields dynamicallydnd-kit is the modern standard for drag-and-drop in React. It offers excellent accessibility, flexible sensor APIs, and performant sortable lists out of the box, making it ideal for form builder interfaces.
Form configurations are typically stored as JSON schemas that describe field types, validation rules, and layout order. This schema can be persisted in a database and rendered dynamically at runtime using a form renderer component.
Yes. By adding a conditions array to each field schema entry, you can define show/hide rules based on other field values. The form renderer evaluates these conditions on each state change to toggle field visibility dynamically.
Collaboration
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI: let's bring your vision to life.
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.
A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.