Modular drag-and-drop form builder for React — bring your own components.
Fjorm is a visual, drag-and-drop form builder and form designer for React 19+. Drag components from a toolbox onto a canvas, configure each field's properties in a sidebar editor, preview the live form, and serialize the result as JSON. The rendering layer is completely pluggable — use raw HTML inputs, Ant Design, MUI, Mantine, or your own design system. Perfect for building form editors, survey creators, page builders, and any tool that needs a visual form constructor.
📖 Full documentation: weeziel172.github.io/fjorm
- Visual form builder — drag components from a palette onto a canvas
- Inline property editing — edit labels, placeholders, required flags, select options
- Preview mode — toggle between builder and rendered-form views
- UI-framework agnostic — register your own display components per field type
- JSON serialization — export/import form structure as portable JSON
- TypeScript-first — full type definitions for the component registry and all APIs
- Lightweight — peer deps: React 19+, react-dom 19+; runtime deps:
@dnd-kit/core,@dnd-kit/sortable,@dnd-kit/utilities,uuid
npm install fjormFjorm requires React 19+ and react-dom 19+ as peer dependencies. Runtime dependencies (@dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities, uuid) are installed automatically:
npm install fjorm react react-domimport { Config, FormBuilder, formComponents } from 'fjorm'
import 'fjorm/dist/index.css'
export default function App() {
const config = new Config()
config.addComponents(formComponents)
return (
<div style={{ height: '100vh' }}>
<FormBuilder config={config} />
</div>
)
}That's it — you get a working drag-and-drop form builder with five built-in field types: Header, Paragraph, TextInput, SelectInput, and Container (for grid/column layouts).
Fjorm's real power comes from swapping in your own UI library. Register custom display components for each field type, and the builder renders them everywhere — on the canvas, in the preview, and in the final form.
import { Form, Input, Select, Typography } from 'antd'
import type { FormComponentRegistration, FormComponentProps } from 'fjorm'
// 1. Define display components wrapping Ant Design primitives
function AntTextInput({ settings, label }: FormComponentProps) {
return (
<Form.Item label={label} name={settings.name}
rules={settings.required ? [{ required: true, message: 'Required' }] : undefined}>
<Input placeholder={settings.placeholder as string} />
</Form.Item>
)
}
// 2. Define a form wrapper for the preview/display mode
function FormWrapper({ children }: { children: React.ReactNode }) {
return <Form layout="vertical">{children}<button type="submit">Save</button></Form>
}
// 3. Register everything in the component array
const myComponents: FormComponentRegistration[] = [
{
key: 'TextInput',
icon: FaTextHeight,
settings: { label: 'Text input', name: 'TextInput' },
component: AntTextInput,
editor: { label: 'EditorInput', placeholder: 'EditorInput', name: 'EditorInput', required: 'EditorCheckbox' },
},
// ... other field types
]
// 4. Wire it up
const config = new Config()
config.addComponents(myComponents)
<FormBuilder config={config} form={{ component: FormWrapper }} />The editor property on each registration tells Fjorm what sidebar editor fields to show. Use the declarative object form (shown above) for common editor field combinations, or pass a custom React component for full control.
All examples are accessible in a single playground app with client-side routing. Run them all from one dev server, or browse the live demos.
| Example | Route | Live Demo |
|---|---|---|
| Basic Demo | / |
Live |
| Ant Design v6 | /#/antd |
Live |
| Material UI v9 | /#/mui |
Live |
| Mantine v9 | /#/mantine |
Live |
| Custom Builder | /#/custom |
Live |
Each example includes a FormWrapper, several field types, editor definitions, and a ready-to-run Vite setup. The Custom Builder example demonstrates composing a form builder and display from fjorm's primitives — ToolBox, FormContainer, EditorToolBox, and all public hooks.
Fjorm supports nested layouts through Container components. A Container acts as a droppable zone within the canvas — drag components into it to build grid, column, or section-based form layouts. Containers can be nested (container within container) for complex multi-level structures.
The default formComponents array includes a Container component. Set isContainer: true to enable nested droppable zones. The layout is controlled by your component — the built-in example supports Grid, Flex Row, and Flex Column via the layout setting:
{
key: 'Container',
isContainer: true,
settings: { label: 'Container', name: 'container', layout: 'grid', columns: 2, gap: '0.75rem' },
icon: GridIcon,
component: ExampleContainer,
editor: {
label: 'EditorInput',
name: 'EditorInput',
layout: { type: 'EditorSelect', options: [
{ value: 'grid', label: 'Grid' },
{ value: 'flex-row', label: 'Flex Row' },
{ value: 'flex-column', label: 'Flex Column' },
]},
columns: 'EditorInput',
gap: 'EditorInput',
},
providesValue: false,
}Drag a Container onto the canvas, then drag other components (TextInput, Select, etc.) into the container body. Each container automatically gets a nested drop zone. The children prop is passed to your component — you control the layout.
Replace the built-in CSS Grid container with your UI framework's grid system by providing a custom component:
// Ant Design — Row/Col grid
function AntRowContainer({ children, settings }: FormComponentProps) {
return (
<Row gutter={16}>
{React.Children.map(children, (child) => (
<Col span={24 / ((settings.columns as number) || 2)}>{child}</Col>
))}
</Row>
)
}
// Mantine — SimpleGrid
function MantineGridContainer({ children, settings }: FormComponentProps) {
return (
<SimpleGrid cols={(settings.columns as number) || 2}>
{children}
</SimpleGrid>
)
}
// MUI — Grid container
function MuiGridContainer({ children, settings }: FormComponentProps) {
const cols = (settings.columns as number) || 2
return (
<Grid container spacing={2}>
{React.Children.map(children, (child) => (
<Grid size={12 / cols}>{child}</Grid>
))}
</Grid>
)
}The children prop contains rendered RecursiveItem child components. The Container infrastructure (nested SortableContext, inner useDroppable zone, tree serialization) is framework-agnostic — only the visual layout component needs to be swapped.
Containers use a tree structure. Each FormItem and SerializedFormItem has an optional children array:
interface FormItem {
id: string
key: string
settings: FormComponentSettings
// ...
children?: FormItem[] // nested child items (for containers)
}
interface SerializedFormItem {
id: string
key: string
settings: FormComponentSettings
// ...
children?: SerializedFormItem[]
}Serialization and deserialization handle nesting automatically. Drag-and-drop between containers, from canvas to container, and container to canvas are all supported.
- Toolbox → Container: Adds a new component as a child of that container
- Canvas → Container: Moves an existing item into the container
- Container → Canvas: Moves an item out of the container to the top level
- Within Container: Reorder children inside the same container
- Between Containers: Move an item from one container to another
All moves preserve the item's settings, options, and values.
The central registry. Register form component definitions.
class Config {
get components(): FormComponentRegistration[]
getComponent(key: string): FormComponentRegistration | undefined
addComponents(arr: readonly FormComponentRegistration[]): void
}addComponents warns on duplicate keys and rebuilds the internal lookup index. Use getComponent(key) for O(1) lookups instead of indexing into the components array directly.
Each registered field type follows this shape:
interface FormComponentRegistration {
key: string // unique identifier, e.g. "TextInput"
settings: FormComponentSettings // default field settings (label, name, …)
icon?: ComponentType // icon shown in the toolbox
component: ComponentType<FormComponentProps> // display component
editor: ComponentType<EditorProps> | EditorFieldMap // editor definition
options?: FormComponentOption[] // default options (for selects)
providesValue?: boolean // set to false for display-only components (headers, paragraphs)
isContainer?: boolean // when true, renders a nested droppable zone
}Two editor modes:
- Function — pass any React component receiving
EditorProps - Object — declarative key→editor-type mapping, e.g.
{ label: 'EditorInput', required: 'EditorCheckbox' }. Values can be strings ('EditorInput') or objects with options ({ type: 'EditorSelect', options: [...] }). Available editor types:EditorInput,EditorCheckbox,EditorTextArea,EditorOptions,EditorSelect.
<FormBuilder
ref={builderRef} // FormBuilderHandle — getFormItems(), reset()
config={config} // Config instance (required)
form={{ component: FormWrapper }} // custom form wrapper for preview mode
initialData={savedForm} // pre-populate the builder with serialized data
onChange={(structure) => {}} // called when form structure changes (drag, edit, delete)
onSubmit={(formData) => {}} // called when the default preview form is submitted
/>Note:
onSubmitonly fires for the default<form>(no custom wrapper). When using a customform.component, the wrapper handles its own submission — wire your submit logic inside the wrapper instead.
Imperative handle — access via useRef<FormBuilderHandle>:
interface FormBuilderHandle {
getFormItems(): SerializedFormItem[] // current form structure as JSON
reset(): void // clear all form items
}Standalone read-only form renderer (used internally by FormBuilder in preview mode):
<FormDisplay
data={serializedItems} // SerializedFormItem[]
config={config} // Config instance
form={formWrapper} // optional custom form wrapper
/>import { serializeFormItems, deserializeFormItems } from 'fjorm'
// Export form structure as portable JSON
const json: SerializedFormItem[] = serializeFormItems(formItems)
// Rehydrate from saved JSON
const formItems: FormItem[] = deserializeFormItems(json, config)The props interface every display component receives. Your adapter components (Ant Design, MUI, Mantine wrappers) are built against this shape:
interface FormComponentProps {
id: string // Unique item ID
label: string // Display label from settings
style?: CSSProperties // Optional style overrides
settings: FormComponentSettings // All settings for this field
options?: FormComponentOption[] // Options (for selects, checkboxes, etc.)
children?: ReactNode // Optional children
editMode?: boolean // True when rendered in builder canvas
value?: unknown // Pre-filled value from serialized data
onChangeValue?: (value: unknown) => void // Push value for complex components
onChangeFormItemSettings?: (payload) => void // Notify setting changes
onClick?: (payload) => void // Click handler (builder mode)
editor?: EditorDefinition // Editor definition for the field
}Custom form wrapper configuration passed to <FormBuilder form={...}>:
interface FormConfig {
component: ComponentType<FormConfigProps> // custom form wrapper component
actions?: ReactNode // optional actions (buttons, etc.)
}
interface FormConfigProps {
children?: ReactNode
onSubmit?: (data: Record<string, unknown>) => void
fjormValues: Record<string, unknown> // all onChangeValue-tracked values
}Props received by editor components:
interface EditorProps {
settings: FormComponentSettings
options?: FormComponentOption[]
formItemId: string
onValueChange: (payload: { name: string; value: unknown }) => void
onChangeOptions?: (payload: { name: string; options: FormComponentOption[] }) => void
}Build your own display components (see Adapter Pattern above) — the library provides the framework, not the fields.
Components:
| Export | Kind | Description |
|---|---|---|
Config |
Class | Component registry |
FormBuilder |
Component | Main builder UI (named export) |
FormDisplay |
Component | Standalone read-only form renderer |
FormContainer |
Component | Drag-and-drop canvas |
ToolBox |
Component | Component palette |
EditorToolBox |
Component | Editor sidebar panel |
EditorContainer |
Component | Renders editor fields for a form item |
FormComponentWrapper |
Component | Wraps form item with edit/delete actions |
EditorInput |
Component | Text editor field |
EditorCheckbox |
Component | Boolean toggle editor field |
EditorTextArea |
Component | Multi-line text editor field |
EditorOptions |
Component | Options list editor (add/remove/edit rows) |
EditorSelect |
Component | Dropdown select editor with configurable options |
EditorCompiler |
Component | Converts EditorFieldMap to rendered editor components |
FormComponentInput |
Component | Default text input display |
FormComponentSelect |
Component | Default select dropdown display |
FormComponentHeader |
Component | Default heading display |
FormComponentParagraph |
Component | Default paragraph display |
ErrorBoundary |
Component | Error boundary wrapper |
FormComponentEditorContainer |
Component | Editor layout wrapper |
FormItemLabel |
Component | Form field label with required badge |
FormItemDisplay |
Component | Label + control layout wrapper |
ComponentEditActions |
Component | Edit/delete action buttons |
Tag |
Component | Small pill badge |
Option |
Component | Single option row (value + title) |
ToolboxItem |
Component | Toolbox palette card |
FormComponentContainer |
Component | Minimal container display shell |
RecursiveItem |
Component | Dispatches container vs regular item rendering |
Hooks & Utilities:
| Export | Kind | Description |
|---|---|---|
formComponents |
Value | Default component definitions (Header, Paragraph, TextInput, SelectInput, Container) |
serializeFormItems |
Function | Convert form items to portable JSON |
deserializeFormItems |
Function | Rehydrate JSON back to form items |
getSetting |
Function | Type-safe settings access helper |
useFormItems |
Hook | Form items state management |
useFormBuilderDragDrop |
Hook | DnD event handling via @dnd-kit |
useEditorChange |
Hook | Editor change handler |
useEditorState |
Hook | Local editor state |
useOptionsManager |
Hook | Options list CRUD |
Types:
import type {
FormComponentSettings,
FormComponentOption,
EditorProps,
FormComponentProps,
EditorDefinition,
EditorFieldMap,
EditorFieldDescriptor,
FormComponentRegistration,
FormItem,
SerializedFormItem,
FormConfig,
FormConfigProps,
EditorChangePayload,
DragEndPayload,
DndActive,
DndOver,
DndItemData,
FormBuilderHandle,
} from 'fjorm'# Install dependencies
yarn install
# Build the library (ESM + CJS + type declarations)
yarn build
# Watch mode
yarn dev
# Run tests
yarn test
# Run tests in watch mode
yarn test:watch
# Run the playground (demo + all examples in one app)
cd demo && yarn devThis project uses semantic-release for fully automated versioning. Commits to main trigger the CI pipeline, which determines the next version from commit messages, publishes to npm, and creates a GitHub Release.
Commit conventions (Conventional Commits):
fix: fix crash when deleting edited form item → patch release (1.0.0 → 1.0.1)
feat: add checkbox field type → minor release (1.0.0 → 1.1.0)
feat: redesign public API
BREAKING CHANGE: Config.addComponents signature → major release (1.0.0 → 2.0.0)
No manual version bumping, tagging, or release drafting needed — merge to main and semantic-release handles the rest.
fjorm/
├── src/
│ ├── index.ts # public API barrel
│ ├── types.ts # TypeScript type definitions
│ ├── styles.css # builder UI styles
│ ├── utils/
│ │ ├── config.ts # Config class
│ │ ├── getSetting.ts # type-safe settings access
│ │ ├── useDragDrop.ts # useFormBuilderDragDrop hook
│ │ ├── useEditorChange.ts # editor change handler
│ │ ├── useEditorState.ts # local editor state hook
│ │ ├── useFormItems.ts # useFormItems + serialization
│ │ └── useOptionsManager.ts # option CRUD hook
│ └── components/
│ ├── atoms/ # 8 primitive components
│ ├── molecules/ # 9 composite components
│ ├── organisms/ # 9 business-logic components
│ ├── componentUtils/ # dynamic editor compiler
│ └── builderComponents.ts # default component definitions
├── tests/
│ ├── setup.ts
│ └── unit/ # 142 tests across 19 files
├── demo/ # Playground SPA (demo + all examples)
├── examples/ # Standalone reference projects
│ ├── antd/ # Ant Design v6 integration
│ ├── mui/ # Material UI v9 integration
│ └── mantine/ # Mantine v9 integration
├── tsup.config.ts # library build config
├── vitest.config.ts # test config
└── tsconfig.json
Fjorm supports two value paths — native inputs are captured automatically via the browser's FormData, and complex non-native components use the onChangeValue callback.
Default form (built-in <form>):
- Simple components (
<input>,<select>,<textarea>) are uncontrolled — the browser owns their state - Complex components (list switchers, tag pickers, custom widgets) call
onChangeValueto push their value into the form - On submit, tracked values take priority over
FormData, and unchecked checkboxes default tofalse
Custom form wrapper (UI library integration):
- The wrapper receives
fjormValues: Record<string, unknown>as a prop — allonChangeValue-tracked values - The wrapper merges
fjormValueswith its own form state on submit - See the Mantine example's
FormWrapperfor a working implementation
Pre-filling values:
const data: SerializedFormItem[] = [
{ id: '1', key: 'TextInput', settings: { label: 'Email', name: 'email' }, value: 'prefilled@test.com' },
{ id: '2', key: 'ListSwitcher', settings: { label: 'Pages', name: 'pages' }, value: ['1', '3'] },
]
<FormDisplay data={data} config={config} onSubmit={handleSubmit} />Building a component that uses onChangeValue:
function ListSwitcher({ settings, options, value, onChangeValue }: FormComponentProps) {
const [selected, setSelected] = useState<Set<string>>(
new Set(Array.isArray(value) ? (value as string[]) : []),
)
function toggle(id: string) {
const next = new Set(selected)
next.has(id) ? next.delete(id) : next.add(id)
setSelected(next)
onChangeValue?.(Array.from(next)) // push array up to the form
}
return (
<div>
<label>{settings.label}</label>
{(options ?? []).map((item) => (
<button
key={item.id}
onClick={() => toggle(item.id)}
style={{ background: selected.has(item.id) ? 'blue' : 'gray' }}
>
{item.title}
</button>
))}
</div>
)
}The editor for this component uses EditorOptions to let users configure the selectable items — see demo/src/examples/mantine/formComponents.tsx (or examples/mantine/src/formComponents.tsx) for the full working version.
When the declarative editor object isn't enough, compose a custom editor from Fjorm's primitives:
import {
EditorInput,
EditorCheckbox,
EditorTextArea,
EditorOptions,
FormComponentEditorContainer,
useEditorChange,
type EditorProps,
} from 'fjorm'
function MyCustomEditor({ settings, options, onValueChange, onChangeOptions }: EditorProps) {
const handleOnChange = useEditorChange(onValueChange)
return (
<FormComponentEditorContainer>
<EditorInput settings={settings} name="label" label="Label" handleOnChange={handleOnChange} />
<EditorInput
settings={settings}
name="name"
label="Field name"
handleOnChange={handleOnChange}
/>
<EditorCheckbox
settings={settings}
name="required"
label="Required"
handleOnChange={handleOnChange}
/>
<EditorOptions
options={options}
settings={settings}
name="options"
label="Options"
handleOnChange={handleOnChange}
handleOnChangeOptions={onChangeOptions ?? (() => {})}
/>
</FormComponentEditorContainer>
)
}
// Use it as the editor:
const registration: FormComponentRegistration = {
key: 'MyComponent',
settings: { label: 'My Component', name: 'myComponent' },
icon: MyIcon,
component: MyDisplayComponent,
editor: MyCustomEditor, // function form instead of declarative object
}Available primitives:
| Export | Purpose |
|---|---|
EditorInput |
Text input (label, placeholder, name fields) |
EditorCheckbox |
Boolean toggle (required field) |
EditorTextArea |
Multi-line text (content field) |
EditorOptions |
Add/remove/edit option rows (title + value) |
FormComponentEditorContainer |
Layout wrapper with consistent padding |
useEditorChange |
Hook — converts EditorChangePayload → { name, value } for onValueChange |
The declarative object form ({ label: 'EditorInput', required: 'EditorCheckbox' }) is just syntactic sugar — EditorCompiler maps those keys to these same primitives. Use the function form when you need custom layout, conditional fields, or validation beyond what the declarative form supports.
Built on @dnd-kit (DndContext, useSortable, useDroppable, DragOverlay). The toolbox uses useDraggable for palette items. The canvas uses SortableContext + useSortable for reorderable form items. Components with isContainer: true get a nested useDroppable zone wrapped in their own SortableContext. A DragOverlay renders a drag preview that follows the cursor. Custom collision detection combines closestCorners (for sortable precision) with pointerWithin (for container/empty-area detection).
- Define display components wrapping your UI library's primitives
- Define editor components (or use declarative editor objects)
- Create a
FormComponentRegistration[]array - Call
config.addComponents(yourArray) - Pass
configto<FormBuilder>
The config builds an internal formComponentMappings index (key → array position) for O(1) lookups during drag operations.
MIT © WEeziel172

