@dmitryvim/form-builder
v0.8.0
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 (no
decimals→ value is kept as typed; setdecimalsto round on extraction) - Select: Dropdown with options and default values. Renders a leading empty option (label:
placeholderor a localized "Select…"); with no selection the field extracts asnull, andrequiredfails until an option is chosen (v0.6.0) - 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
- Lookup: One value picked from a list your backend serves through the
lookuphook, with search (v0.8.0+) - Folder source: A folder as a ZIP upload or a public Yandex Disk / Google Drive link (v0.8.0+)
- Table source: A table as a CSV/XLSX upload, a Google Sheet link, or typed in an inline grid (v0.8.0+)
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.8.0: data of `lookup` fields — required when a lookup is rendered.
// Exactly one of query (search, "" on first open) / values (resolve stored values) is set.
lookup?: (request: {
source: string; fieldPath: string; signal: AbortSignal;
query?: string; values?: string[];
}) => Promise<Array<{ value: string; label: string; description?: string; image?: string }>>,
// v0.8.0: optional previews under folder_source / table_source fields.
// ctx = { signal, limit, mode: 'edit' | 'readonly' }; resolve null = no preview.
previewTableSource?: (source: TableSourceValue, ctx: SourcePreviewContext) => Promise<TableSourcePreview | null>,
previewFolderSource?: (source: FolderSourceValue, ctx: SourcePreviewContext) => Promise<FolderSourcePreview | null>,
// 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.6.0: postMessage opt-in for submitForm()/saveDraft().
// null (default) = never post. Set the parent frame's origin — or "*" to
// knowingly broadcast — to receive {type: "formSubmit"|"formDraft", data, schema}
// messages on window.parent. (Before 0.6.0 the library always broadcast with "*".)
postMessageTarget?: string | null,
// 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 — a silent read, safe to poll (v0.7.0: never paints errors)
const result = form.getFormData(); // { valid, errors, data }
// v0.7.0: on the submit click, paint every error next to its field ...
const checked = form.showErrors(); // same { valid, errors, data }
// ... and scroll to / focus the first one (does not validate itself)
if (!checked.valid) form.focusFirstError(); // returns false when nothing is marked
// 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"
Lookup Element (v0.8.0+)
Picks one value from a list your backend serves. The library never fetches: it calls the lookup hook, which is required whenever a lookup field is rendered. Sources are declared once in the schema root.
{
"sources": { "products": { "load": "search" }, "brands": { "load": "all" } },
"elements": [
{
"type": "lookup",
"key": "product",
"label": "Product",
"source": "products",
"placeholder": "Name or SKU",
"required": true
}
]
}const form = createFormBuilder({
lookup: async ({ source, query, values, signal }) => {
const params = values
? { ids: values.join(",") } // resolve stored values (prefill, readonly)
: { q: query }; // search; "" on first open, and the one call of a "load": "all" source
const res = await fetch(`/api/${source}?${new URLSearchParams(params)}`, {
signal,
});
return res.json(); // [{ value, label, description?, image? }]
},
});- Value: the chosen
valuestring, ornull. Non-string values throw. load: "search"(default): a combobox with a search input; every query goes to the hook, debounced 250 ms, and stale requests are aborted.load: "all": the hook is called once withquery: ""and returns the whole list, cached for the field (a failed load is retried on the next open). Filtering runs in the browser, instantly, on label and description. With more than 10 items the field gets a search input; with 10 or fewer it is a plain dropdown.- Stored values (prefill, readonly) are resolved through
valuesin both modes; the whole list is never loaded just to show one label. - A leftover
searchkey in a source is a schema error. - A host error is shown inside the field and is not a validation error. Readonly shows the resolved row without controls.
multipleis not supported.
Folder and Table Source Elements (v0.8.0+)
folder_source takes a folder, table_source takes a table. The user picks how on a tab strip built from sources.
[
{
"type": "folder_source",
"key": "photos",
"label": "Photos",
"sources": ["zip", "yadisk", "gdrive"],
"maxSize": 100
},
{
"type": "table_source",
"key": "sheet",
"label": "Price list",
"sources": ["csv", "xlsx", "gsheet", "inline"],
"maxRows": 1000
}
]Value — a descriptor, or null:
| Kind | Value |
| ---------------------------- | ------------------------------------------------------------------- |
| zip, csv, xlsx | { "kind": "zip", "file": "<what uploadFile returned>" } |
| yadisk, gdrive, gsheet | { "kind": "gdrive", "url": "<link as pasted, trimmed>" } |
| inline (table only) | { "kind": "inline", "columns": ["A", "B"], "rows": [["1", "2"]] } |
- File kinds upload through
uploadFile(required when a file kind is allowed);maxSizeis in MB. - Links are recognised by shape: Yandex Disk
/d/…, Google Drive/drive/folders/…, Google Sheets/spreadsheets/d/…. A link of another allowed kind switches the tab; an unrecognised one is a field error. - The inline grid accepts a range pasted from Excel or Google Sheets; the first row holds the headers.
multipleis not supported.
Previews (optional). Configure previewTableSource / previewFolderSource to show a few rows or the folder tree under the field. They receive { signal, limit, mode } and may resolve null. A rejection's message is shown as a notice and does not block submit.
const form = createFormBuilder({
uploadFile,
previewTableSource: async (source, { signal, limit, mode }) =>
mode === "readonly"
? storedImportPreview(source) // never re-import in readonly
: fetchSheetPreview(source, limit, signal),
// => { title, sheet, sheets, columns, rows, totalRows } | null
});In readonly mode (mode: "readonly") answer from the result you stored at import time, never by re-importing: the form shows what was submitted, not what the link points to today. Full hook signatures and validation rules: CHANGELOG.md (0.8.0).
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: see CHANGELOG.md
- License: MIT - see LICENSE file
Built with ❤️ for the web development community.
