@dmitryvim/form-builder
v0.5.1
Published
A reusable JSON schema form builder library
Maintainers
Readme
Form Builder
JSON Schema → Dynamic Forms → Structured Output
A comprehensive, zero-dependency form generation library that converts JSON Schema v0.3 into dynamic, interactive HTML forms with advanced file handling, real-time validation, internationalization, and extensive field type support.
Live Demo
Try it now: https://picazru.github.io/form-builder/dist/index.html
Quick Start
CDN Integration
<!-- Embed via iframe -->
<iframe
src="https://picazru.github.io/form-builder/dist/index.html"
width="100%"
height="600px"
frameborder="0"
></iframe>
<!-- With custom schema -->
<iframe
src="https://picazru.github.io/form-builder/dist/index.html?schema=BASE64_SCHEMA"
width="100%"
height="600px"
></iframe>NPM Installation
npm install @dmitryvim/form-builderTypeScript Setup
This package ships with complete TypeScript definitions. Ensure your tsconfig.json is configured for modern module resolution:
{
"compilerOptions": {
"moduleResolution": "NodeNext", // or "Bundler" for Vite/Webpack
"esModuleInterop": true,
"skipLibCheck": false
}
}No need for custom .d.ts files - types are auto-resolved from the package at dist/types/index.d.ts.
Core Features
- 🎯 Schema-driven forms: JSON Schema v0.3 → Interactive forms with live preview
- 📁 Advanced file handling: Images, videos, documents with drag-and-drop, tile preview, and per-file upload progress
- ✅ Real-time validation: Client-side validation with visual feedback and error display
- 🌍 Internationalization: Built-in English/Russian support with extensible translation system
- 🎨 Rich field types: Text, textarea, number, select, file, files, and nested groups
- 👁️ Read-only mode: Display form data without editing capabilities
- 🔘 Action buttons: Configurable buttons in readonly mode for custom interactions
- 💾 Draft saving: Save incomplete forms without validation
- 🔧 Framework agnostic: Works with any web stack (React, Vue, Angular, vanilla JS)
- 📦 Zero dependencies: Self-contained HTML/CSS/JavaScript
- 📱 Responsive design: Mobile-friendly with Tailwind CSS styling
- 🏗️ Instance-based architecture: Multiple independent forms with isolated state
New in v0.2.0
- 🔄 onChange Events: Real-time change notifications with debouncing (eliminates polling)
- 🎨 CSS Theming: 43 configurable theme properties (no
!importantoverrides) - 📝 Partial Updates: Update fields without re-rendering (
setFormData,updateField) - ⚡ Async Thumbnails: Dynamic thumbnail loading with async-only
getThumbnail
Impact: Eliminates ~280+ lines of workaround code for Klein integration
Quick Examples
Root-Level Properties (v0.2.9+)
The root schema now supports container-like properties:
columns- Grid layout for root-level fields (1-4 columns)prefillHints- Quick-fill templates for entire form
Example:
{
"version": "0.3",
"title": "Contact Form",
"columns": 2,
"prefillHints": [
{
"label": "Work",
"values": { "email": "[email protected]" },
"icon": "💼"
},
{
"label": "Personal",
"values": { "email": "[email protected]" },
"icon": "🏠"
}
],
"elements": [
{ "type": "text", "key": "name", "label": "Name" },
{ "type": "text", "key": "email", "label": "Email" }
]
}See CLAUDE.md for complete documentation.
Simple Contact Form
{
"title": "Contact Form",
"elements": [
{
"type": "text",
"key": "name",
"label": "Full Name",
"required": true,
"minLength": 2,
"maxLength": 50
},
{
"type": "textarea",
"key": "message",
"label": "Message",
"placeholder": "Your message here...",
"required": true,
"rows": 4
},
{
"type": "files",
"key": "attachments",
"label": "Attachments",
"description": "Upload supporting documents or images",
"maxCount": 3,
"maxSizeMB": 10,
"accept": {
"extensions": ["pdf", "jpg", "png", "docx"]
}
}
]
}Advanced Product Form with Actions
{
"title": "Product Registration",
"elements": [
{
"type": "file",
"key": "mainImage",
"label": "Product Image",
"required": true,
"accept": {
"extensions": ["jpg", "png", "webp"]
},
"actions": [
{ "key": "enhance", "label": "Enhance Quality" },
{ "key": "crop", "label": "Auto Crop" },
{ "key": "retry", "label": "Try Again" }
]
},
{
"type": "group",
"key": "specifications",
"label": "Product Specifications",
"repeat": { "min": 1, "max": 10 },
"elements": [
{
"type": "text",
"key": "name",
"label": "Specification Name",
"required": true
},
{
"type": "text",
"key": "value",
"label": "Value",
"required": true
}
]
}
]
}Integration Methods
1. NPM Package (Recommended)
npm install @dmitryvim/form-builder// ES6 imports
import { createFormBuilder } from "@dmitryvim/form-builder";
// Create form instance with v0.3.0 features
const formBuilder = createFormBuilder({
uploadFile: async (file) => {
// Your upload logic - return resource ID
return "resource-123";
},
downloadFile: (resourceId, fileName) => {
// Handle file download
window.open(`/api/files/${resourceId}`, "_blank");
},
getThumbnail: async (resourceId) => {
// v0.2.0: Async-only thumbnail loading
const url = await fetchThumbnailUrl(resourceId);
return url;
},
actionHandler: (value, key, field) => {
// Handle action button clicks
console.log("Action clicked:", { value, key, field });
},
// v0.2.0: Real-time change events
onChange: (formData) => {
console.log("Form changed:", formData);
if (formData.valid) autoSave(formData.data);
},
// v0.2.0: CSS theming
theme: {
primaryColor: "#0066cc",
borderRadius: "4px",
},
});
// Render form
const rootElement = document.getElementById("form-container");
formBuilder.renderForm(rootElement, schema, prefillData);
// Get form data
const result = formBuilder.getFormData();
if (result.valid) {
console.log("Form data:", result.data);
}
// v0.2.0: Update fields without re-rendering
formBuilder.updateField("email", "[email protected]");
formBuilder.setFormData({ name: "John", email: "[email protected]" });2. CDN Integration
<!-- Direct script include (npm CDN) -->
<script src="https://cdn.jsdelivr.net/npm/@dmitryvim/form-builder@latest/dist/form-builder.js"></script>
<script>
const { createFormBuilder } = window.FormBuilder;
const formBuilder = createFormBuilder({
uploadFile: async (file) => "resource-id",
downloadFile: (resourceId) => {
/* download */
},
});
const rootElement = document.getElementById("form-container");
formBuilder.renderForm(rootElement, schema);
</script>
<!-- Or use our S3 CDN -->
<script src="https://picaz-form-builder.website.yandexcloud.net/form-builder/latest/form-builder.js"></script>
<!-- Embed complete demo -->
<iframe
src="https://picaz-form-builder.website.yandexcloud.net/form-builder/latest/index.html"
width="100%"
height="600px"
frameborder="0"
>
</iframe>3. Self-Hosted Deployment
Download and serve the dist/ folder contents.
See Integration Guide for detailed setup instructions.
Hosting Requirements
Mounting inside hidden or deferred-layout containers
text and textarea (with autoExpand: true) fields measure their content height on mount to set an initial size. If the host mounts the form inside a container that has no real layout at that moment — e.g. display: none, visibility: hidden, width: 0, an inactive tab, a collapsed accordion, a not-yet-opened modal — the initial measurement reads scrollHeight === 0 and the textarea would otherwise collapse to its borders (~2 px).
Since v0.3.2 the library recovers automatically: a width-keyed ResizeObserver is attached to every auto-expanding textarea and re-runs the height calculation when the host reveals the container (width transitions 0 → real) or resizes its column.
You still get the smoothest UX by mounting the form after the container has its final layout. Two reliable patterns:
// Pattern A — mount on tab/modal open, not on page load
modal.addEventListener("shown", () => {
formBuilder.renderForm(rootElement, schema, prefill);
});
// Pattern B — already mounted? Force a re-measure by re-applying prefill.
// Each updateField call dispatches an `input` event that re-runs
// the height calculation, even outside of ResizeObserver support.
panel.addEventListener("visible", () => {
formBuilder.setFormData(currentValues);
});If you are stuck on a browser without ResizeObserver (very old) and cannot defer mounting, calling formBuilder.setFormData(...) once the form becomes visible is the supported workaround — it triggers a fresh measurement of every text/textarea field.
Complete Feature Set
Field Types
- Text: Single-line with pattern validation, length limits
- Textarea: Multi-line with configurable rows
- Number: Numeric input with min/max/step/decimals
- Select: Dropdown with options and default values
- Switcher: Segmented button group (all options visible, ≤4) — same data model as Select
- Colour: Colour picker with hex values (single or palette)
- Slider: Range slider with linear/exponential scales (v0.2.7+)
- Table: 2D editable grid with cell merging, keyboard navigation, readonly mode
- File: Single file upload with compact dropzone, tile preview, and type restrictions
- Files: Multiple file upload with 80px tile grid, per-file upload progress, and drag-and-drop
- Container: Nested containers with repeatable array support
- Markdown: Display-only text block rendered from markdown syntax (v0.2.30+); never included in form data output
File Handling
- Supported formats: Images (jpg, png, gif, webp), Videos (mp4, webm, mov), Documents (pdf, docx, etc.)
- Preview system: Thumbnails for images, video players, document icons
- Drag-and-drop: Visual feedback and multi-file support
- Validation: File size, type, and count restrictions
- Resource management: Metadata tracking and automatic cleanup
Validation & UX
- Real-time validation: As-you-type with visual feedback
- Schema validation: Comprehensive error reporting
- Internationalization: English/Russian built-in, extensible
- Tooltips: Field descriptions and hints
- Responsive design: Mobile-friendly interface
- Read-only mode: Display data without editing
- Action buttons: Custom buttons in readonly mode with configurable handlers
API Reference
Core Methods
import { createFormBuilder } from '@dmitryvim/form-builder';
// Create form instance
const form = createFormBuilder({
// Required handlers
uploadFile: async (file: File) => string, // Upload file, return resource ID
downloadFile: (resourceId: string, fileName: string) => void,
// Optional handlers
getThumbnail?: async (resourceId: string) => string | null, // v0.2.0: Async-only, for preview thumbnails
getDownloadUrl?: (resourceId: string) => string | null, // Optional: URL for downloading full file
actionHandler?: (value: any, key: string, field: any) => void,
// v0.2.28: Host-owned file library picker (optional)
// When configured, adds an equal-prominence "From library" trigger alongside upload.
// resolve([]) = silent cancel; reject/throw = error shown in the field.
// PickedResource is exported from the package root.
pickExistingFiles?: (context: {
fieldPath: string; // bracket-notation path, e.g. "slides[2].image"
mode: 'single' | 'multiple';
accept?: { extensions?: string[]; mime?: string[] };
maxSizeMB?: number; // Infinity if no limit
remainingSlots?: number; // multi mode only
selectedResourceIds?: string[];
}) => Promise<PickedResource[]>,
// v0.2.0: onChange events
onChange?: (formData: FormResult) => void,
onFieldChange?: (fieldPath: string, value: any, formData: FormResult) => void,
debounceMs?: number, // Default: 300ms
// v0.2.4: Verbose error logging
verboseErrors?: boolean, // Default: false (memory leak warnings, validation details)
// v0.2.0: CSS theming
theme?: {
primaryColor?: string,
borderRadius?: string,
fontSize?: string,
fontFamily?: string,
// ... 39 more theme properties
}
});
// Render form
form.renderForm(rootElement, schema, prefillData);
// Get form data
const result = form.getFormData(); // { valid, errors, data }
// v0.2.0: Update data without re-rendering
form.setFormData({ name: 'John', email: '[email protected]' });
form.updateField('email', '[email protected]');
form.updateField('address.city', 'New York'); // Nested paths
// Switch modes
form.setMode('readonly'); // or 'edit'
// Cleanup
form.destroy();Migration from v0.1.x
Breaking Changes in v0.2.0:
- Instance-Only API - Global static methods removed
// OLD (v0.1.x)
FormBuilder.configure({ uploadFile: async (file) => "id" });
FormBuilder.renderForm(root, schema);
// NEW (v0.2.0)
const form = createFormBuilder({ uploadFile: async (file) => "id" });
form.renderForm(root, schema);- Async getThumbnail - Must return Promise
// OLD (v0.1.x)
getThumbnail: (resourceId) => `/thumbs/${resourceId}.jpg`;
// NEW (v0.2.0)
getThumbnail: async (resourceId) => `/thumbs/${resourceId}.jpg`;See CHANGELOG.md for complete migration details.
Theming
Customize appearance with 43 theme properties:
const form = createFormBuilder({
theme: {
// Colors
primaryColor: "#0066cc",
borderColor: "#e0e0e0",
errorColor: "#d32f2f",
// Typography
fontSize: "16px",
fontFamily: '"Roboto", sans-serif',
// Spacing
borderRadius: "4px",
inputPaddingX: "12px",
// Buttons
buttonBgColor: "#0066cc",
buttonTextColor: "#ffffff",
},
});Most Common Properties:
primaryColor- Primary brand colorborderRadius- Border radius for inputs/buttonsfontSize- Base font sizefontFamily- Font familyborderColor- Input border color
No !important overrides needed. See CHANGELOG.md for all 43 properties.
File Handling
The form builder provides flexible file handling with separate hooks for uploads, downloads, and previews:
const form = createFormBuilder({
// Required: Upload handler
uploadFile: async (file) => {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});
const data = await response.json();
return data.resourceId; // Return unique resource ID
},
// Required: Download handler
downloadFile: (resourceId, fileName) => {
// Option 1: Direct download
window.open(`/api/files/${resourceId}/download`, "_blank");
// Option 2: Programmatic download
fetch(`/api/files/${resourceId}/download`)
.then((response) => response.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
});
},
// Optional: Thumbnail URLs for preview (async-only in v0.2.0)
getThumbnail: async (resourceId) => {
const response = await fetch(`/api/files/${resourceId}/thumbnail`);
const data = await response.json();
return data.thumbnailUrl; // Return URL or null
},
// Optional: Full file download URL (used instead of getThumbnail for downloads)
getDownloadUrl: (resourceId) => {
return `/api/files/${resourceId}/download`;
},
});Handler Priority for Downloads:
When a user clicks the download button in readonly mode, the form builder uses this priority:
getDownloadUrl- If provided, this URL is used for downloadsgetThumbnail- IfgetDownloadUrlis not provided, falls back to thumbnail URLdownloadFile- If neither URL hook is provided, calls the download handler
Use Cases:
- Separate thumbnail and download URLs: Provide both
getThumbnail(for optimized previews) andgetDownloadUrl(for full files) - Same URL for both: Provide only
getThumbnailif thumbnail URL is also the download URL - Programmatic download: Provide only
downloadFilefor custom download logic
Important Note on Async Handlers (v0.2.4):
getThumbnail must be async (return Promise). TypeScript enforces this at compile-time. If you provide a synchronous handler, the error will occur at first file usage (not at form instantiation). This is intentional - we avoid validation calls that would trigger unnecessary API requests. Follow the FAIL FAST principle: errors surface naturally at usage.
File Library Picker (v0.2.28+)
Let users pick from already-uploaded files without re-uploading. Configure the optional pickExistingFiles handler to open your own media library modal.
import { createFormBuilder, PickedResource } from "@dmitryvim/form-builder";
const form = createFormBuilder({
uploadFile: async (file) => "resource-id",
pickExistingFiles: async (ctx) => {
// ctx.fieldPath — bracket-notation path, e.g. "images[0].cover"
// ctx.mode — "single" | "multiple"
// ctx.accept — { extensions?, mime? }
// ctx.remainingSlots — multi mode: how many more files fit
// ctx.selectedResourceIds — IDs already in this field (for UI deselection)
const chosen = await openYourLibraryModal(ctx);
// resolve([]) to cancel silently
return chosen; // PickedResource[]
},
});PickedResource interface:
interface PickedResource {
resourceId: string;
name: string;
type: string; // MIME type
size: number; // bytes
}UI behavior:
- Without
pickExistingFiles: no UI change (identical to v0.2.27). - With
pickExistingFiles: empty state shows two equal cards (upload + library). Multi-file fields show two add-tiles ("+"/upload and library icon). Both are hidden whenmaxCountis reached. - Single-file with a file already selected: no library trigger (existing tile occupies slot).
Validation: The library re-validates resolved items using the same rules as upload (accept.extensions, maxSize, maxCount, dedupe). Host-side filtering in your picker UI is advisory.
Conditional Field Visibility
Show or hide fields based on form data values using the enableIf property:
interface EnableCondition {
key: string; // Field key to check (supports nested paths)
equals?: any; // Value to compare (initial operator)
scope?: "relative" | "absolute"; // v0.2.9: Evaluation context (default: "relative")
// Future: notEquals, in, exists, greaterThan, lessThan, and/or
}Scope Behavior (v0.2.9+):
scope: "relative"(default): Checks fields within current containerscope: "absolute": Checks fields in root-level form data- Use absolute scope when referencing root-level fields from inside containers
Simple Condition:
{
"type": "select",
"key": "background_mode",
"label": "Background Mode",
"options": [
{ "value": "use_color", "label": "Solid Color" },
{ "value": "use_image", "label": "Image" }
]
},
{
"type": "text",
"key": "background_image",
"label": "Background Image URL",
"enableIf": {
"key": "background_mode",
"equals": "use_image"
}
}Nested Path:
{
"type": "text",
"key": "city_details",
"label": "City-Specific Information",
"enableIf": {
"key": "address.city",
"equals": "New York"
}
}Array Indices:
{
"type": "text",
"key": "first_item_note",
"label": "Note for First Item",
"enableIf": {
"key": "items[0].quantity",
"equals": 1
}
}Absolute Scope (v0.2.9+):
{
"type": "container",
"key": "shipping_options",
"elements": [
{
"type": "text",
"key": "express_note",
"label": "Express Shipping Note",
"enableIf": {
"key": "shipping_type",
"equals": "express",
"scope": "absolute"
}
}
]
}Hide Entire Sections:
{
"type": "container",
"key": "advanced_settings",
"label": "Advanced Settings",
"enableIf": {
"key": "mode",
"equals": "advanced"
},
"elements": [
// All child elements are hidden when container is hidden
]
}How It Works:
- Fields with unmet conditions are not rendered and excluded from form data
- Conditions are evaluated before rendering each element
- Automatic re-evaluation when dependency fields change (integrated with onChange system)
- Works in both edit and readonly modes
- Safe handling of undefined/null values
Per-Field Readonly
Set readonly: true on any individual field to lock it while keeping the rest of the form editable.
{
"elements": [
{
"type": "file",
"key": "avatar",
"label": "Avatar",
"readonly": true
},
{
"type": "text",
"key": "name",
"label": "Name",
"required": true
}
]
}In this example, the prefilled avatar image shows as a preview (no upload/delete UI) while the name field remains fully editable.
Behavior:
- Effective readonly for a field =
element.readonly === trueOR form-widesetMode('readonly') - Readonly file fields show preview + download only; no upload or delete controls
- Readonly multi-value fields (
multiple: true) render items without add/remove buttons readonly: trueon a container propagates to all descendants recursively — childreadonly: falsedoes not override an ancestor's readonly (matches<fieldset disabled>semantics)- Orthogonal to
enableIf: hidden fields stay hidden; visible readonly fields render readonly
Container readonly example — all children render readonly even though they have no readonly flag of their own:
{
"type": "container",
"key": "locked_section",
"label": "Locked Section",
"readonly": true,
"elements": [
{ "type": "text", "key": "ref_code", "label": "Reference Code" },
{ "type": "number", "key": "amount", "label": "Amount" }
]
}Path Resolution:
- Dot notation:
user.profile.name,settings.theme.primaryColor - Array indices:
items[0].name,addresses[1].city - Mixed:
users[0].profile.email
Future Extensibility:
The enableIf system is designed for future expansion:
// Future operators (planned)
{
"enableIf": {
"key": "age",
"greaterThan": 18
}
}
{
"enableIf": {
"key": "status",
"in": ["active", "pending"]
}
}
// Future complex conditions (planned)
{
"enableIf": {
"and": [
{ "key": "role", "equals": "admin" },
{ "key": "verified", "equals": true }
]
}
}Table Element
An editable 2D grid with plain-text cells, cell merging, and full keyboard navigation.
Schema:
{
"type": "table",
"key": "data",
"label": "Data Table",
"columns": 4,
"rows": 5
}Data format — a 2D array of strings with optional merge definitions:
{
"cells": [
["Product", "Q1", "Q2", "Q3"],
["Widget A", "100", "120", "140"],
["Widget B", "80", "90", "95"]
],
"merges": [{ "row": 0, "col": 0, "rowspan": 1, "colspan": 2 }]
}Keyboard shortcuts:
- Tab / Shift+Tab — move to next/previous cell
- Arrow keys — navigate between cells
- Enter — confirm cell and move down
- Escape — cancel edit
- Ctrl+M — merge selected cells
- Ctrl+Shift+M — split merged cell
Properties:
| Property | Type | Default | Description |
| --------- | -------- | ------- | -------------------- |
| columns | number | 3 | Initial column count |
| rows | number | 3 | Initial row count |
Features:
- Plain-text cell editing via
contentEditable - First row visually highlighted as header
- Add/remove rows and columns at runtime
- Cell merging (colspan/rowspan) with Ctrl+M
- Readonly mode renders a static
<table> - Full i18n support (6 translation keys:
tableAddRow,tableAddColumn,tableRemoveRow,tableRemoveColumn,tableMergeCells,tableSplitCell)
Markdown Element (v0.2.30+)
A display-only element that renders markdown as safe HTML. It never appears in getFormData() output.
Schema:
{
"type": "markdown",
"content": "# Welcome\n\nPlease fill in **all required fields** below.\n\nSee the [guidelines](https://example.com) for details."
}With optional key and conditional visibility:
{
"type": "markdown",
"key": "terms-note",
"content": "## Terms Apply\n\nBy submitting you agree to our [terms](https://example.com/terms).",
"enableIf": { "key": "agreed", "equals": "yes" }
}Properties:
| Property | Type | Required | Description |
| ---------- | -------- | -------- | --------------------------------------------------------- |
| type | string | Yes | Must be "markdown" |
| content | string | Yes | Markdown source text |
| key | string | No | Optional identifier; field never contributes to form data |
| enableIf | object | No | Conditional visibility (same as other field types) |
Supported markdown syntax:
- Headings:
# H1through###### H6 - Emphasis:
**bold**,*italic*,~~strikethrough~~ - Inline code:
`code` - Fenced code blocks:
```language ... ``` - Links:
[text](url) - Lists: ordered (
1.) and unordered (-,*) - Blockquotes:
> text - Horizontal rules:
---
XSS Protection:
- Raw
<,>,&characters in content are escaped before parsing — HTML tags in markdown source cannot render as DOM elements javascript:,data:,vbscript:href schemes are stripped from all links- All rendered links get
target="_blank" rel="noopener noreferrer"
Documentation
- API Reference - See above for core methods and theming
- Development Guide - CLAUDE.md for architecture and workflow
- Changelog - CHANGELOG.md for version history and migration
Live Demo
Try it now: https://picaz-form-builder.website.yandexcloud.net/form-builder/latest/index.html
Version Index: https://picaz-form-builder.website.yandexcloud.net/index.html
Features a 3-column interface:
- Schema Editor: Edit JSON schema with validation
- Live Preview: See form render in real-time
- Data Output: View/export form data as JSON
Support & Contributing
- GitHub: picazru/form-builder
- NPM Package: @dmitryvim/form-builder
- CDN: picaz-form-builder.website.yandexcloud.net
- Version: 0.2.28
- License: MIT - see LICENSE file
Built with ❤️ for the web development community.
